├── .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 | [](https://www.python.org/downloads/release/python-390/)
4 | [](https://opensource.org/licenses/MIT)
5 | [](https://zenn.dev/naoki0103/articles/image-stitcher-application)
6 |
7 |
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 |
--------------------------------------------------------------------------------