├── .github └── workflows │ ├── clean_gitkeep.yml │ └── lint.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── data └── __init__.py ├── docs ├── assets │ ├── case1.png │ ├── case2.png │ ├── input.png │ ├── result.png │ └── rotated.png └── source │ └── conf.py ├── notebooks └── tutorial.ipynb ├── poetry.lock ├── poetry.toml ├── pyproject.toml └── src ├── __init__.py ├── main.py └── utils ├── __init__.py └── visualizer.py /.github/workflows/clean_gitkeep.yml: -------------------------------------------------------------------------------- 1 | name: Clean unnecessary .gitkeep files 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | clean: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v3 16 | 17 | - name: Remove unnecessary .gitkeep files 18 | run: | 19 | find . -name '.gitkeep' -print0 | while IFS= read -r -d '' file; do 20 | dir=$(dirname "$file") 21 | if [ "$(ls -A "$dir" | wc -l)" -gt "1" ]; then 22 | echo "Removing $file" 23 | rm "$file" 24 | fi 25 | done 26 | 27 | - name: Commit changes 28 | run: | 29 | git config --local user.email "action@github.com" 30 | git config --local user.name "GitHub Action" 31 | git add -A 32 | if [ -n "$(git status --porcelain)" ]; then 33 | git commit -m ":fire: Remove unnecessary .gitkeep files" 34 | git push 35 | else 36 | echo "No changes to commit" 37 | fi 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: python lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | paths: 8 | - "src/**.py" 9 | - "tests/**.py" 10 | - "poetry.lock" 11 | - ".github/workflows/lint.yml" 12 | 13 | jobs: 14 | lint: 15 | name: Lint 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.9", "3.10"] 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v2 25 | 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install Poetry 32 | run: | 33 | curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python - 34 | echo "$HOME/.poetry/bin" >> $GITHUB_PATH 35 | 36 | - uses: actions/cache@v2 37 | id: venv_cache 38 | with: 39 | path: .venv 40 | key: venv-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} 41 | 42 | - name: Install Dependencies 43 | if: steps.venv_cache.outputs.cache-hit != 'true' 44 | run: poetry install 45 | 46 | - name: Python Lint 47 | run: poetry run task lint 48 | 49 | - name: Python Test 50 | run: poetry run task test 51 | -------------------------------------------------------------------------------- /.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 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["ms-python.python", "njpwerner.autodocstring"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "charliermarsh.ruff", // Ruff: it is a fast linter for Python, written in Rust. 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.ruff": "explicit", // Fix codes when saving 6 | "source.organizeImports.ruff": "explicit", // Organize imports order when saving 7 | }, 8 | "editor.formatOnSave": true, 9 | "editor.tabSize": 4, 10 | }, 11 | "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", 12 | "autoDocstring.docstringFormat": "numpy", 13 | "makefile.configureOnOpen": false, 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Naoki Chihara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON_VERSION = 3.10 2 | 3 | .PHONY: install 4 | install: check_pyenv check_poetry pyenv_setup poetry_setup 5 | @VENV_PATH=$$(poetry env info --path); \ 6 | SITE_PACKAGES_DIR="$${VENV_PATH}/lib/python3.10/site-packages"; \ 7 | PROJECT_ROOT=$$(echo $${VENV_PATH} | rev | cut -d'/' -f2- | rev); \ 8 | VENV_NAME=$$(basename `dirname $${VENV_PATH}`); \ 9 | echo $${PROJECT_ROOT} > $${SITE_PACKAGES_DIR}/$${VENV_NAME}.pth 10 | 11 | .PHONY: check_pyenv 12 | check_pyenv: 13 | @if ! command -v pyenv &> /dev/null; then \ 14 | echo "Error: "pyenv" is not installed. please visit https://github.com/pyenv/pyenv#installation in details."; \ 15 | exit 1; \ 16 | fi 17 | 18 | .PHONY: check_poetry 19 | check_poetry: 20 | @if ! command -v poetry &> /dev/null; then \ 21 | echo "Error: "pyenv" is not installed. please visit https://python-poetry.org/docs/#installation in details."; \ 22 | exit 1; \ 23 | fi 24 | 25 | .PHONY: pyenv_setup 26 | pyenv_setup: 27 | pyenv install -s $(PYTHON_VERSION) 28 | pyenv global $(PYTHON_VERSION) 29 | 30 | .PHONY: poetry_setup 31 | poetry_setup: 32 | poetry env use $(PYTHON_VERSION) 33 | poetry install 34 | 35 | .PHONY: run 36 | run: 37 | python src/main.py 38 | 39 | .PHONY: pre-commit 40 | pre-commit: 41 | poetry run pre-commit run --all-files 42 | 43 | .PHONY: test 44 | test: 45 | poetry run pytest -s -vv --cov=. --cov-branch --cov-report=html 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # image-stitcher 2 | 3 | [![Python 3.10](https://img.shields.io/badge/Python-3.10-green.svg)](https://www.python.org/downloads/release/python-390/) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![RubyDoc](https://img.shields.io/badge/%F0%9F%93%9AZenn-documentation-informational.svg)](https://zenn.dev/naoki0103/articles/image-stitcher-application) 6 | Stars Badge 7 | Forks Badge 8 | 9 | This is a python implementation for stitching images by automatically searching for overlap region. 10 | - [👨‍💻 Usage](#-usage) 11 | - [🎯 Preview of results](#-preview-of-results) 12 | - [🧠 Main Idea](#-main-idea) 13 | - [🙋‍♂️ Support](#️-support) 14 | - [✉️ Contact](#️-contact) 15 | 16 | ## 👨‍💻 Usage 17 | ```python 18 | from src.main import main 19 | from src.utils.visualizer import result_visualize 20 | 21 | merged_image, cand = main( 22 | image1=image1, # The first image to be combined 23 | image2=image2, # The second image to be combined 24 | min_overlap=(5, 5), # The minimum overlap region 25 | verbose=False, # Whether to print the log 26 | ) 27 | result_visualize( 28 | image1=image1, # The first image to be combined 29 | image2=image2, # The second image to be combined 30 | merged_image=merged_image, # The output image 31 | cand=cand, # The parameters 32 | ) 33 | ``` 34 | 35 | 36 | ## 🎯 Preview of results 37 | The results using [`CIFAR-10`](https://www.cs.toronto.edu/~kriz/cifar.html) are shown below. I would refer you to [`tutorial.ipynb`](https://github.com/C-Naoki/image-stitcher/blob/main/notebooks/tutorial.ipynb) for detailed results. 38 | 39 |

40 | 41 |

42 | Figure 1. The example of input images. The red area represents an empty region. This application can combine these images while considering their rotation. 43 |

44 | 45 |

46 | 47 |

48 | Figure 2. The preprocessed input images. This rotation process is necessary to accurately combine the images. The green frame represents the overlap region between the input images. 49 |

50 | 51 |

52 | 53 |

54 | Figure 3. The output image. 55 |

56 | 57 | ## 🧠 Main Idea 58 |

59 | 60 |

61 | Figure 4. The overview of this application in limited case. 62 |

63 | 64 | This application is designed based on the overlap region's width $w_c$ and height $h_c$. Thanks to this idea, we can simply limit the search space, thus preventing it from capturing overly small, suboptimal overlap region. 65 | 66 |

67 | 68 |

69 | Figure 5. The overview of this application. 70 |

71 | 72 | However, the above approach is not always applicable, specifically when $\min(h_1, h_2) < h_c$ or $\min(w_1, w_2) < w_c$. To address this issue, I change the perspective of $w_c$ and $h_c$ like the above figure. Therefore, this application can handle images of arbitrary sizes. 73 | 74 | 75 | ## 🙋‍♂️ Support 76 | 💙 If you like this app, give it a ⭐ and share it with friends! 77 | 78 | ## ✉️ Contact 79 | 💥 For questions or issues, feel free to open an [issue](https://github.com/C-Naoki/image-stitcher/issues). I appreciate your feedback and look forward to hearing from you! 80 | -------------------------------------------------------------------------------- /data/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | import matplotlib.pyplot as plt 4 | import numpy as np 5 | import pandas as pd 6 | from matplotlib.patches import Rectangle 7 | from skimage.transform import rotate 8 | from sklearn.datasets import fetch_openml 9 | 10 | 11 | def load_data(idx: int = 0) -> Union[np.ndarray, pd.Series]: 12 | cifar10 = fetch_openml("CIFAR_10") 13 | 14 | data = cifar10["data"] 15 | target = cifar10["target"] 16 | X = np.array(data).reshape(-1, 3, 32, 32).astype(np.uint8) 17 | 18 | return np.transpose(X[idx], (1, 2, 0)), target 19 | 20 | 21 | def split_data( 22 | image: np.ndarray, slicex: tuple, slicey: tuple, angle: int = 0, plot: bool = False 23 | ) -> Union[np.ndarray, np.ndarray]: 24 | if plot: 25 | viz_image1 = np.zeros_like(image) 26 | viz_image1[:, :, 0] = 255 27 | viz_image1[slicey[0], slicex[0], :] = image[slicey[0], slicex[0], :] 28 | viz_image2 = np.zeros_like(image) 29 | viz_image2[:, :, 0] = 255 30 | viz_image2[slicey[1], slicex[1], :] = image[slicey[1], slicex[1], :] 31 | 32 | fig, ax = plt.subplots(1, 3, figsize=(7, 3)) 33 | 34 | ax[0].imshow(image) 35 | ax[0].set_title(f"Original {image.shape[1]}x{image.shape[0]}") 36 | ax[0].axis("off") 37 | 38 | ax[1].imshow(viz_image1) 39 | ax[1].set_title(f"First {slicey[0].stop - slicey[0].start}x{slicex[0].stop - slicex[0].start}") 40 | ax[1].axis("off") 41 | 42 | ax[2].imshow(viz_image2) 43 | ax[2].set_title(f"Second {slicey[1].stop - slicey[1].start}x{slicex[1].stop - slicex[1].start}") 44 | ax[2].axis("off") 45 | 46 | rect1 = Rectangle( 47 | (max(slicex[0].start, slicex[1].start), max(slicey[0].start, slicey[1].start)), 48 | min(slicex[0].stop, slicex[1].stop) - max(slicex[0].start, slicex[1].start) - 1, 49 | min(slicey[0].stop, slicey[1].stop) - max(slicey[0].start, slicey[1].start) - 1, 50 | linewidth=7, 51 | edgecolor="g", 52 | facecolor="none", 53 | ) 54 | rect2 = Rectangle( 55 | (max(slicex[0].start, slicex[1].start), max(slicey[0].start, slicey[1].start)), 56 | min(slicex[0].stop, slicex[1].stop) - max(slicex[0].start, slicex[1].start) - 1, 57 | min(slicey[0].stop, slicey[1].stop) - max(slicey[0].start, slicey[1].start) - 1, 58 | linewidth=7, 59 | edgecolor="g", 60 | facecolor="none", 61 | ) 62 | 63 | ax[1].add_patch(rect1) 64 | ax[2].add_patch(rect2) 65 | 66 | image1 = image[slicey[0], slicex[0], :] 67 | image2 = image[slicey[1], slicex[1], :] 68 | 69 | rotated_image2 = rotate(image2, angle, resize=True, preserve_range=True).astype(np.uint8) 70 | 71 | return image1, rotated_image2 72 | -------------------------------------------------------------------------------- /docs/assets/case1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Naoki/image-stitcher/1c2b75b8952d63fc8a24046c2960e4edfa6ab10c/docs/assets/case1.png -------------------------------------------------------------------------------- /docs/assets/case2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Naoki/image-stitcher/1c2b75b8952d63fc8a24046c2960e4edfa6ab10c/docs/assets/case2.png -------------------------------------------------------------------------------- /docs/assets/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Naoki/image-stitcher/1c2b75b8952d63fc8a24046c2960e4edfa6ab10c/docs/assets/input.png -------------------------------------------------------------------------------- /docs/assets/result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Naoki/image-stitcher/1c2b75b8952d63fc8a24046c2960e4edfa6ab10c/docs/assets/result.png -------------------------------------------------------------------------------- /docs/assets/rotated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Naoki/image-stitcher/1c2b75b8952d63fc8a24046c2960e4edfa6ab10c/docs/assets/rotated.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from sphinx_pyproject import SphinxConfig 5 | 6 | sys.path.append( 7 | os.path.abspath(f"{os.path.dirname(os.path.abspath(__file__))}/../../") 8 | ) 9 | 10 | config = SphinxConfig("../../pyproject.toml", globalns=globals()) 11 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | path = ".venv" 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "image-stitcher" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["naoki "] 6 | readme = "README.md" 7 | package-mode = false 8 | 9 | [tool.poetry.group.dev.dependencies] 10 | ruff = "^0.4.9" 11 | 12 | [project] 13 | name = "image-stitcher" 14 | version = "0.1.0" 15 | description = "Personal Python Template" 16 | readme = "README.md" 17 | 18 | [tool.poetry.dependencies] 19 | python = ">=3.10,<3.11" 20 | numpy = "^1.24.3" 21 | pandas = "^2.0.2" 22 | matplotlib = "^3.7.1" 23 | scikit-learn = "^1.2.2" 24 | jupyter = "^1.0.0" 25 | ipykernel = "^6.23.2" 26 | seaborn = "^0.12.2" 27 | tqdm = "^4.65.0" 28 | pygithub = "^1.58.2" 29 | 30 | scikit-image = "^0.24.0" 31 | [tool.poetry.dev-dependencies] 32 | pre-commit = "^2.18.1" 33 | taskipy = "^1.10.1" 34 | mypy = "^0.990" 35 | pep8-naming = "^0.12.1" 36 | pytest = "^7.1.1" 37 | pytest-mock = "^3.7.0" 38 | pytest-cov = "^3.0.0" 39 | Sphinx = "^4.5.0" 40 | sphinx-rtd-theme = "^1.0.0" 41 | sphinx-pyproject = "^0.1.0" 42 | 43 | [tool.ruff] 44 | target-version = "py39" 45 | line-length = 119 46 | 47 | [tool.ruff.lint] 48 | select = ["E", "W", "F", "C"] 49 | fixable = ["ALL"] 50 | 51 | [tool.ruff.format] 52 | quote-style = "double" 53 | 54 | [tool.mypy] 55 | show_error_context = true 56 | show_column_numbers = true 57 | ignore_missing_imports = true 58 | disallow_untyped_defs = true 59 | no_implicit_optional = true 60 | warn_return_any = true 61 | warn_unused_ignores = true 62 | warn_redundant_casts = true 63 | 64 | [tool.sphinx-pyproject] 65 | project = "image-stitcher" 66 | copyright = "2023, naoki" 67 | language = "en" 68 | package_root = "image-stitcher" 69 | html_theme = "sphinx_rtd_theme" 70 | todo_include_todos = true 71 | templates_path = ["_templates"] 72 | html_static_path = ["_static"] 73 | extensions = [ 74 | "sphinx.ext.autodoc", 75 | "sphinx.ext.viewcode", 76 | "sphinx.ext.todo", 77 | "sphinx.ext.napoleon", 78 | ] 79 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Naoki/image-stitcher/1c2b75b8952d63fc8a24046c2960e4edfa6ab10c/src/__init__.py -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | 3 | import numpy as np 4 | from skimage.metrics import mean_squared_error 5 | from skimage.transform import rotate 6 | 7 | 8 | @dataclasses.dataclass 9 | class Coordinate: 10 | x: int 11 | y: int 12 | 13 | 14 | def main(image1, image2, min_overlap=(5, 5), verbose=False): 15 | h1, w1, _ = image1.shape 16 | h2, w2, _ = image2.shape 17 | min_h, min_w = min_overlap 18 | core_x1 = [0, w1] 19 | core_y1 = [0, h1] 20 | 21 | cand = init_candparam() 22 | for angle in [0, 90, 180, 270]: 23 | # 画像を回転 24 | _image2 = rotate(image2, angle, resize=True, preserve_range=True).astype(np.uint8) 25 | h2, w2, _ = _image2.shape 26 | core_x2 = [0, w2] 27 | core_y2 = [0, h2] 28 | for hc in range(min_h, max(h1, h2) + 1): 29 | for wc in range(min_w, max(w1, w2) + 1): 30 | for ix in range(2): 31 | for iy in range(2): 32 | topleft1 = Coordinate( 33 | min(core_x1[ix] + (-1) ** ix * max(wc - w2, 0), core_x1[ix] + (-1) ** ix * min(wc, w1)), 34 | min(core_y1[iy] + (-1) ** iy * max(hc - h2, 0), core_y1[iy] + (-1) ** iy * min(hc, h1)), 35 | ) 36 | bottomright1 = Coordinate( 37 | max(core_x1[ix] + (-1) ** ix * max(wc - w2, 0), core_x1[ix] + (-1) ** ix * min(wc, w1)), 38 | max(core_y1[iy] + (-1) ** iy * max(hc - h2, 0), core_y1[iy] + (-1) ** iy * min(hc, h1)), 39 | ) 40 | overlap1 = image1[topleft1.y : bottomright1.y, topleft1.x : bottomright1.x, :] 41 | if verbose: 42 | print(f"image1's top-left: {topleft1}") 43 | print(f"image1's bottom-right: {bottomright1}") 44 | topleft2 = Coordinate( 45 | min( 46 | core_x2[ix ^ 1] + (-1) ** (ix ^ 1) * max(wc - w1, 0), 47 | core_x2[ix ^ 1] + (-1) ** (ix ^ 1) * min(wc, w2), 48 | ), 49 | min( 50 | core_y2[iy ^ 1] + (-1) ** (iy ^ 1) * max(hc - h1, 0), 51 | core_y2[iy ^ 1] + (-1) ** (iy ^ 1) * min(hc, h2), 52 | ), 53 | ) 54 | bottomright2 = Coordinate( 55 | max( 56 | core_x2[ix ^ 1] + (-1) ** (ix ^ 1) * max(wc - w1, 0), 57 | core_x2[ix ^ 1] + (-1) ** (ix ^ 1) * min(wc, w2), 58 | ), 59 | max( 60 | core_y2[iy ^ 1] + (-1) ** (iy ^ 1) * max(hc - h1, 0), 61 | core_y2[iy ^ 1] + (-1) ** (iy ^ 1) * min(hc, h2), 62 | ), 63 | ) 64 | overlap2 = _image2[topleft2.y : bottomright2.y, topleft2.x : bottomright2.x, :] 65 | if verbose: 66 | print(f"image2's top-left: {topleft2}") 67 | print(f"image2's bottom-right: {bottomright2}") 68 | mse = mean_squared_error(overlap1, overlap2) 69 | if mse < cand["best_mse"]: 70 | cand["best_mse"] = mse 71 | cand["best_rotation"] = angle 72 | cand["topleft1"] = topleft1 73 | cand["topleft2"] = topleft2 74 | cand["bottomright1"] = bottomright1 75 | cand["bottomright2"] = bottomright2 76 | cand["height"] = hc 77 | cand["width"] = wc 78 | 79 | revised_image2 = rotate(image2, cand["best_rotation"], resize=True, preserve_range=True).astype(np.uint8) 80 | h2, w2, _ = revised_image2.shape 81 | hc, wc = cand["height"], cand["width"] 82 | merged_image = np.zeros((h1 + h2 - cand["height"], w1 + w2 - cand["width"], 3), dtype=np.uint8) 83 | merged_image = np.zeros( 84 | (max(h1, h2) + max(min(h1, h2) - cand["height"], 0), max(w1, w2) + max(min(w1, w2) - cand["width"], 0), 3), 85 | dtype=np.uint8, 86 | ) 87 | merged_image[:, :, 0] = 255 88 | hm, wm, _ = merged_image.shape 89 | 90 | print("\n==== BEST RESULT ====") 91 | print(f'Best MSE: {cand["best_mse"]}') 92 | print(f'Best rotation: {cand["best_rotation"]}') 93 | print(f"image1's shape: ({h1}, {w1})") 94 | print(f"image2's shape: ({h2}, {w2})") 95 | print(f'shared image\'s top-left in image1: {cand["topleft1"]}') 96 | print(f'shared image\'s bottom-right in image1: {cand["bottomright1"]}') 97 | print(f'shared image\'s top-left in (rotated) image2: {cand["topleft2"]}') 98 | print(f'shared image\'s bottom-right in (rotated) image2: {cand["bottomright2"]}') 99 | print(f"shared image's height: {hc}") 100 | print(f"shared image's width: {wc}") 101 | 102 | left1 = 1 if cand["topleft1"].x == 0 else 0 103 | left2 = 1 if cand["topleft2"].x == 0 else 0 104 | top1 = 1 if cand["topleft1"].y == 0 else 0 105 | top2 = 1 if cand["topleft2"].y == 0 else 0 106 | merged_image[(h2 - hc) * top1 : h1 + (h2 - hc) * top1, (w2 - wc) * left1 : w1 + (w2 - wc) * left1, :] = image1 107 | merged_image[(h1 - hc) * top2 : h2 + (h1 - hc) * top2, (w1 - wc) * left2 : w2 + (w1 - wc) * left2, :] = ( 108 | revised_image2 109 | ) 110 | 111 | return merged_image, cand 112 | 113 | 114 | def init_candparam(): 115 | return { 116 | "best_mse": float("inf"), 117 | "best_rotation": None, 118 | "topleft1": None, 119 | "topleft2": None, 120 | "bottomright1": None, 121 | "bottomright2": None, 122 | "height": None, 123 | "width": None, 124 | } 125 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/C-Naoki/image-stitcher/1c2b75b8952d63fc8a24046c2960e4edfa6ab10c/src/utils/__init__.py -------------------------------------------------------------------------------- /src/utils/visualizer.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | from matplotlib.patches import Rectangle 4 | from skimage.transform import rotate 5 | 6 | 7 | def result_visualize(image1, image2, merged_image, cand): 8 | fig, ax = plt.subplots(1, 2, figsize=(6, 3)) 9 | h1, w1, _ = image1.shape 10 | h2, w2, _ = image2.shape 11 | hm, wm, _ = merged_image.shape 12 | 13 | revised_image1 = np.zeros_like(merged_image) 14 | revised_image1[:, :, :] = 255 15 | revised_image1[(hm - h1) // 2 : (hm + h1) // 2, (wm - w1) // 2 : (wm + w1) // 2] = image1 16 | revised_image2 = np.zeros_like(merged_image) 17 | revised_image2[:, :, :] = 255 18 | revised_image2[(hm - h2) // 2 : (hm + h2) // 2, (wm - w2) // 2 : (wm + w2) // 2] = image2 19 | 20 | ax[0].imshow(revised_image1) 21 | ax[0].set_title("Image1") 22 | 23 | ax[1].imshow(revised_image2) 24 | ax[1].set_title("Image2") 25 | 26 | fig.suptitle("Input Images") 27 | fig.align_labels() 28 | fig.tight_layout() 29 | 30 | fig, ax = plt.subplots(1, 2, figsize=(6, 3)) 31 | 32 | rotated_image2 = rotate(image2, cand["best_rotation"], resize=True, preserve_range=True).astype(np.uint8) 33 | h2, w2, _ = rotated_image2.shape 34 | revised_image2 = np.zeros_like(merged_image) 35 | revised_image2[:, :, :] = 255 36 | revised_image2[(hm - h2) // 2 : (hm + h2) // 2, (wm - w2) // 2 : (wm + w2) // 2] = rotated_image2 37 | 38 | hc = min(cand["height"], h1, h2) 39 | wc = min(cand["width"], w1, w2) 40 | rect1 = Rectangle( 41 | (cand["topleft1"].x + (wm - w1) // 2, cand["topleft1"].y + (hm - h1) // 2), 42 | wc - 1, 43 | hc - 1, 44 | linewidth=7, 45 | edgecolor="g", 46 | facecolor="none", 47 | ) 48 | rect2 = Rectangle( 49 | (cand["topleft2"].x + (wm - w2) // 2, cand["topleft2"].y + (hm - h2) // 2), 50 | wc - 1, 51 | hc - 1, 52 | linewidth=7, 53 | edgecolor="g", 54 | facecolor="none", 55 | ) 56 | 57 | ax[0].add_patch(rect1) 58 | ax[1].add_patch(rect2) 59 | 60 | ax[0].imshow(revised_image1) 61 | ax[0].set_title("Image1") 62 | 63 | ax[1].imshow(revised_image2) 64 | ax[1].set_title("Image2") 65 | 66 | fig.suptitle(f"Rotated Input Images ({cand['best_rotation']}°)") 67 | fig.align_labels() 68 | fig.tight_layout() 69 | 70 | fig, ax = plt.subplots(1, 1, figsize=(3, 3)) 71 | 72 | ax.imshow(merged_image) 73 | ax.set_title("Merged Image") 74 | 75 | fig.align_labels() 76 | fig.tight_layout() 77 | 78 | plt.show() 79 | --------------------------------------------------------------------------------