shinestacker 1.8.1__tar.gz → 1.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of shinestacker might be problematic. Click here for more details.
- {shinestacker-1.8.1 → shinestacker-1.9.0}/CHANGELOG.md +13 -0
- {shinestacker-1.8.1/src/shinestacker.egg-info → shinestacker-1.9.0}/PKG-INFO +3 -1
- {shinestacker-1.8.1 → shinestacker-1.9.0}/README.md +3 -1
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/gui.md +13 -13
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/main.md +2 -1
- shinestacker-1.9.0/src/shinestacker/_version.py +1 -0
- shinestacker-1.9.0/src/shinestacker/algorithms/exif.py +486 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/multilayer.py +6 -4
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/stack.py +25 -13
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/stack_framework.py +2 -2
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/utils.py +18 -2
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/vignetting.py +1 -1
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/config/constants.py +0 -1
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/action_config_dialog.py +2 -1
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/folder_file_selection.py +3 -2
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/gui_run.py +2 -2
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/new_project.py +5 -5
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/exif_data.py +3 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/file_loader.py +3 -3
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/io_gui_handler.py +4 -4
- {shinestacker-1.8.1 → shinestacker-1.9.0/src/shinestacker.egg-info}/PKG-INFO +3 -1
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker.egg-info/SOURCES.txt +0 -1
- shinestacker-1.8.1/img/gui-finder.png +0 -0
- shinestacker-1.8.1/src/shinestacker/_version.py +0 -1
- shinestacker-1.8.1/src/shinestacker/algorithms/exif.py +0 -240
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.coveragerc +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.flake8 +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.github/workflows/ci-multiplatform.yml +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.github/workflows/pylint.yml +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.github/workflows/pypi-publish.yml +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.github/workflows/release.yml +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.gitignore +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.pylintrc +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/.readthedocs.yaml +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/LICENSE +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/MANIFEST.in +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/THIRD_PARTY_LICENSES.txt +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/alignment.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/api.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/balancing.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/conf.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/focus_stacking.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/index.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/job.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/macos-install.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/multilayer.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/noise.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/requirements.txt +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/docs/vignetting.md +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/coffee.gif +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/coffee_stack.jpg +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/extreme-vignetting.jpg +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/flies.gif +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/flies_stack.jpg +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/flow-diagram.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/gui-project-new.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/gui-project-run.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/img/gui-retouch.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/index.html +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/pyproject.toml +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/requirements.txt +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/build_release.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/create_macos_icon.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/git-rev-list.sh +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/hooks/hook-IPython.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/hooks/hook-PySide6.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/hooks/hook-opencv.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/hooks/hook-tests.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/scan_imports.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/shinestacker-inno-setup.iss +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/scripts/validate-tomli.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/setup.cfg +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/__init__.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/__init__.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/align.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/align_auto.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/align_parallel.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/balance.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/base_stack_algo.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/corrections.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/denoise.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/depth_map.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/noise_detection.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/pyramid.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/pyramid_auto.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/pyramid_tiles.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/sharpen.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/algorithms/white_balance.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/__init__.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/about_dialog.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/args_parser_opts.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/gui_utils.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/help_menu.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/main.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/open_frames.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/project.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/retouch.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/app/settings_dialog.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/config/__init__.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/config/app_config.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/config/config.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/config/gui_constants.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/config/settings.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/core/__init__.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/core/colors.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/core/core_utils.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/core/exceptions.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/core/framework.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/core/logging.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/__init__.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/action_config.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/base_form_dialog.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/colors.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/config_dialog.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/flow_layout.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/gui_images.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/gui_logging.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/ico/shinestacker.icns +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/ico/shinestacker.ico +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/ico/shinestacker.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/ico/shinestacker.svg +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/dark/close-round-line-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/dark/forward-button-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/dark/play-button-round-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/dark/plus-round-line-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/dark/shinestacker_bkg.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/light/close-round-line-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/light/forward-button-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/light/play-button-round-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/light/plus-round-line-icon.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/img/light/shinestacker_bkg.png +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/main_window.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/menu_manager.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/project_controller.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/project_converter.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/project_editor.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/project_model.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/recent_file_manager.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/select_path_widget.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/sys_mon.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/tab_widget.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/gui/time_progress_bar.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/__init__.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/adjustments.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/base_filter.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/brush.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/brush_gradient.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/brush_preview.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/brush_tool.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/denoise_filter.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/display_manager.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/filter_manager.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/icon_container.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/image_editor_ui.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/image_view_status.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/image_viewer.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/io_threads.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/layer_collection.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/overlaid_view.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/paint_area_manager.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/shortcuts_help.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/sidebyside_view.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/transformation_manager.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/undo_manager.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/unsharp_mask_filter.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/view_strategy.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/vignetting_filter.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker/retouch/white_balance_filter.py +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker.egg-info/dependency_links.txt +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker.egg-info/entry_points.txt +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker.egg-info/requires.txt +0 -0
- {shinestacker-1.8.1 → shinestacker-1.9.0}/src/shinestacker.egg-info/top_level.txt +0 -0
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
This page reports the main releases only and the main changes therein.
|
|
4
4
|
|
|
5
|
+
|
|
6
|
+
## [v1.9.0] - 2025-10-19
|
|
7
|
+
** Added PNG format support and EXIF failure fix**
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- support of images in PNG format, both in 8 bit and 16 bit depth. Note: EXIF data are not supported for 16 bit PNG because of limitations in the PIL and Open CV python libraries.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- if saving EXIF data fails, a warning is issued instead of stopping the run
|
|
14
|
+
|
|
15
|
+
-----
|
|
16
|
+
|
|
17
|
+
|
|
5
18
|
## [v1.8.1] - 2025-10-16
|
|
6
19
|
** Alignment stability and performance improvements **
|
|
7
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shinestacker
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.0
|
|
4
4
|
Summary: ShineStacker
|
|
5
5
|
Author-email: Luca Lista <luka.lista@gmail.com>
|
|
6
6
|
License-Expression: LGPL-3.0
|
|
@@ -105,11 +105,13 @@ Pyramid methods in image processing
|
|
|
105
105
|
- **Logo**: The Shine Stacker logo was designed by [Alessandro Lista](https://linktr.ee/alelista). Copyright © Alessandro Lista. All rights reserved. The logo is not covered by the LGPL-3.0 license of this project.
|
|
106
106
|
|
|
107
107
|
## Attribution request
|
|
108
|
+
|
|
108
109
|
📸 If you publish images created with Shine Stacker, please consider adding a note such as:
|
|
109
110
|
|
|
110
111
|
*Created with Shine Stacker – https://github.com/lucalista/shinestacker*
|
|
111
112
|
|
|
112
113
|
This is not mandatory, but highly appreciated.
|
|
114
|
+
|
|
113
115
|
---
|
|
114
116
|
> Developed and maintained by [Luca Lista](https://github.com/lucalista).
|
|
115
117
|
> 💡 Contributions, feedback, and feature suggestions are warmly welcome.
|
|
@@ -73,12 +73,14 @@ Pyramid methods in image processing
|
|
|
73
73
|
- **Logo**: The Shine Stacker logo was designed by [Alessandro Lista](https://linktr.ee/alelista). Copyright © Alessandro Lista. All rights reserved. The logo is not covered by the LGPL-3.0 license of this project.
|
|
74
74
|
|
|
75
75
|
## Attribution request
|
|
76
|
+
|
|
76
77
|
📸 If you publish images created with Shine Stacker, please consider adding a note such as:
|
|
77
78
|
|
|
78
79
|
*Created with Shine Stacker – https://github.com/lucalista/shinestacker*
|
|
79
80
|
|
|
80
81
|
This is not mandatory, but highly appreciated.
|
|
82
|
+
|
|
81
83
|
---
|
|
82
84
|
> Developed and maintained by [Luca Lista](https://github.com/lucalista).
|
|
83
85
|
> 💡 Contributions, feedback, and feature suggestions are warmly welcome.
|
|
84
|
-
> If you enjoy Shine Stacker, consider giving it a ⭐️ on GitHub — it really helps visibility!
|
|
86
|
+
> If you enjoy Shine Stacker, consider giving it a ⭐️ on GitHub — it really helps visibility!
|
|
@@ -12,24 +12,28 @@ noisy pixel masking.
|
|
|
12
12
|
|
|
13
13
|
## Starting
|
|
14
14
|
|
|
15
|
-
*
|
|
15
|
+
* The python package can be installed from [PyPI](https://pypi.org/project/shinestacker/) using ```pip```:
|
|
16
16
|
|
|
17
17
|
```console
|
|
18
|
-
>
|
|
18
|
+
> pip install shinestacker
|
|
19
19
|
```
|
|
20
|
+
Onace installed, the GUI app can start either from a console command line :
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
```console
|
|
23
|
+
> shinestacker
|
|
24
|
+
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
* The app can be dowloaded from the [releases page](https://github.com/lucalista/shinestacker/releases):
|
|
27
|
+
- Windows: as installer or ```zip``` archive for local installation
|
|
28
|
+
- macOS: as ```dmg``` disk image, where the app can be dragged to the Application folder
|
|
29
|
+
- Linux: as ```tar.gz``` archive
|
|
26
30
|
|
|
27
31
|
The GUI has two main working areas:
|
|
28
32
|
|
|
29
33
|
* *Project*
|
|
30
34
|
* *Retouch*
|
|
31
35
|
|
|
32
|
-
Switching from *Project* to *Retouch* can be done from the *
|
|
36
|
+
Switching from *Project* to *Retouch* can be done from the *ShineStacker* main menu.
|
|
33
37
|
|
|
34
38
|
## Project area
|
|
35
39
|
|
|
@@ -48,17 +52,13 @@ When the app starts, it proposes to create a new project.
|
|
|
48
52
|
|
|
49
53
|
> **Large Set Tip**: For 100+ images:
|
|
50
54
|
> - Split into 10-15 image "bunches"
|
|
51
|
-
> - Set frame
|
|
55
|
+
> - Set number of overlapping frame from consecutive bunches
|
|
52
56
|
> - Combine intermediate results later
|
|
53
57
|
|
|
54
|
-
> 💡 **RAM Warning**: >15 images of 20Mpx resolution may need 16GB+ RAM. Combine smaller bunches first, if needed, to stack up to hundreds of frames.
|
|
55
|
-
|
|
56
58
|
The newly created project consists of a single job that contains more actions.
|
|
57
59
|
Each action produces a folder as output that has, by default, the action's name.
|
|
58
60
|
Some actions can be combined in order to produce a single intermediate output (alignment, balancing, etc.).
|
|
59
61
|
|
|
60
|
-
**Action Outputs**: 📁 `aligned-balanced/` | 📁 `bunches/` | 📁 `stacked/`
|
|
61
|
-
|
|
62
62
|
> **Pro Tip**: Duplicate jobs when processing similar image sets to save configuration time. You can run multiple jobs in sequence.
|
|
63
63
|
|
|
64
64
|
It is possible to run a single job, or all jobs within a project.
|
|
@@ -67,7 +67,7 @@ It is possible to run a single job, or all jobs within a project.
|
|
|
67
67
|
|
|
68
68
|
### Project Run Tabs
|
|
69
69
|
|
|
70
|
-
1. Job progress bar
|
|
70
|
+
1. Job progress bar with CPU and RAM usage monitor
|
|
71
71
|
2. Real-time log viewer
|
|
72
72
|
3. Retouch button (enabled after processing)
|
|
73
73
|
|
|
@@ -103,5 +103,6 @@ pip install ipywidgets
|
|
|
103
103
|
|
|
104
104
|
| Issue | Workaround |
|
|
105
105
|
|----------|----------------|
|
|
106
|
-
|
|
|
106
|
+
| RAW format unsupported | Convert to TIFF/JPEG first |
|
|
107
|
+
| EXIF data not supported for 16-bit PNG files | convert to 16-bit TIFF first |
|
|
107
108
|
| GUI tests limited | Report any bugs as GitHub issuse |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '1.9.0'
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# pylint: disable=C0114, C0116, W0718, R0911, R0912, E1101, R0915, R1702, R0914, R0917, R0913
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
import traceback
|
|
7
|
+
import cv2
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PIL import Image
|
|
10
|
+
from PIL.TiffImagePlugin import IFDRational
|
|
11
|
+
from PIL.PngImagePlugin import PngInfo
|
|
12
|
+
from PIL.ExifTags import TAGS
|
|
13
|
+
import tifffile
|
|
14
|
+
from .. config.constants import constants
|
|
15
|
+
from .utils import write_img, extension_jpg, extension_tif, extension_png
|
|
16
|
+
|
|
17
|
+
IMAGEWIDTH = 256
|
|
18
|
+
IMAGELENGTH = 257
|
|
19
|
+
RESOLUTIONX = 282
|
|
20
|
+
RESOLUTIONY = 283
|
|
21
|
+
RESOLUTIONUNIT = 296
|
|
22
|
+
BITSPERSAMPLE = 258
|
|
23
|
+
PHOTOMETRICINTERPRETATION = 262
|
|
24
|
+
SAMPLESPERPIXEL = 277
|
|
25
|
+
PLANARCONFIGURATION = 284
|
|
26
|
+
SOFTWARE = 305
|
|
27
|
+
IMAGERESOURCES = 34377
|
|
28
|
+
INTERCOLORPROFILE = 34675
|
|
29
|
+
EXIFTAG = 34665
|
|
30
|
+
XMLPACKET = 700
|
|
31
|
+
STRIPOFFSETS = 273
|
|
32
|
+
STRIPBYTECOUNTS = 279
|
|
33
|
+
NO_COPY_TIFF_TAGS_ID = [IMAGEWIDTH, IMAGELENGTH, RESOLUTIONX, RESOLUTIONY, BITSPERSAMPLE,
|
|
34
|
+
PHOTOMETRICINTERPRETATION, SAMPLESPERPIXEL, PLANARCONFIGURATION, SOFTWARE,
|
|
35
|
+
RESOLUTIONUNIT, EXIFTAG, INTERCOLORPROFILE, IMAGERESOURCES]
|
|
36
|
+
NO_COPY_TIFF_TAGS = ["Compression", "StripOffsets", "RowsPerStrip", "StripByteCounts"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_enclosed_data_for_jpg(data, head, foot):
|
|
40
|
+
size = len(foot.decode('ascii'))
|
|
41
|
+
xmp_start, xmp_end = data.find(head), data.find(foot)
|
|
42
|
+
if xmp_start != -1 and xmp_end != -1:
|
|
43
|
+
return re.sub(
|
|
44
|
+
b'[^\x20-\x7E]', b'',
|
|
45
|
+
data[xmp_start:xmp_end + size]
|
|
46
|
+
).decode().replace('\x00', '').encode()
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_exif(exif_filename):
|
|
51
|
+
if not os.path.isfile(exif_filename):
|
|
52
|
+
raise RuntimeError(f"File does not exist: {exif_filename}")
|
|
53
|
+
image = Image.open(exif_filename)
|
|
54
|
+
if extension_tif(exif_filename):
|
|
55
|
+
return image.tag_v2 if hasattr(image, 'tag_v2') else image.getexif()
|
|
56
|
+
if extension_jpg(exif_filename):
|
|
57
|
+
exif_data = image.getexif()
|
|
58
|
+
with open(exif_filename, 'rb') as f:
|
|
59
|
+
data = extract_enclosed_data_for_jpg(f.read(), b'<?xpacket', b'<?xpacket end="w"?>')
|
|
60
|
+
if data is not None:
|
|
61
|
+
exif_data[XMLPACKET] = data
|
|
62
|
+
return exif_data
|
|
63
|
+
if extension_png(exif_filename):
|
|
64
|
+
exif_data = get_exif_from_png(image)
|
|
65
|
+
return exif_data if exif_data else image.getexif()
|
|
66
|
+
return image.getexif()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_exif_from_png(image):
|
|
70
|
+
exif_data = {}
|
|
71
|
+
try:
|
|
72
|
+
exif_from_image = image.getexif()
|
|
73
|
+
if exif_from_image:
|
|
74
|
+
exif_data.update(dict(exif_from_image))
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
try:
|
|
78
|
+
if hasattr(image, 'text') and image.text:
|
|
79
|
+
for key, value in image.text.items():
|
|
80
|
+
exif_data[f"PNG_{key}"] = value
|
|
81
|
+
if hasattr(image, 'info') and image.info:
|
|
82
|
+
for key, value in image.info.items():
|
|
83
|
+
if key not in ['dpi', 'gamma']:
|
|
84
|
+
exif_data[f"PNG_{key}"] = value
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
return exif_data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def exif_extra_tags_for_tif(exif):
|
|
91
|
+
logger = logging.getLogger(__name__)
|
|
92
|
+
res_x, res_y = exif.get(RESOLUTIONX), exif.get(RESOLUTIONY)
|
|
93
|
+
if not (res_x is None or res_y is None):
|
|
94
|
+
resolution = ((res_x.numerator, res_x.denominator), (res_y.numerator, res_y.denominator))
|
|
95
|
+
else:
|
|
96
|
+
resolution = ((720000, 10000), (720000, 10000))
|
|
97
|
+
res_u = exif.get(RESOLUTIONUNIT)
|
|
98
|
+
resolutionunit = res_u if res_u is not None else 'inch'
|
|
99
|
+
sw = exif.get(SOFTWARE)
|
|
100
|
+
software = sw if sw is not None else "N/A"
|
|
101
|
+
phint = exif.get(PHOTOMETRICINTERPRETATION)
|
|
102
|
+
photometric = phint if phint is not None else None
|
|
103
|
+
extra = []
|
|
104
|
+
for tag_id in exif:
|
|
105
|
+
tag, data = TAGS.get(tag_id, tag_id), exif.get(tag_id)
|
|
106
|
+
if isinstance(data, bytes):
|
|
107
|
+
try:
|
|
108
|
+
if tag_id not in (IMAGERESOURCES, INTERCOLORPROFILE):
|
|
109
|
+
if tag_id == XMLPACKET:
|
|
110
|
+
data = re.sub(b'[^\x20-\x7E]', b'', data)
|
|
111
|
+
data = data.decode()
|
|
112
|
+
except Exception:
|
|
113
|
+
logger.warning(msg=f"Copy: can't decode EXIF tag {tag:25} [#{tag_id}]")
|
|
114
|
+
data = '<<< decode error >>>'
|
|
115
|
+
if isinstance(data, IFDRational):
|
|
116
|
+
data = (data.numerator, data.denominator)
|
|
117
|
+
if tag not in NO_COPY_TIFF_TAGS and tag_id not in NO_COPY_TIFF_TAGS_ID:
|
|
118
|
+
extra.append((tag_id, *get_tiff_dtype_count(data), data, False))
|
|
119
|
+
else:
|
|
120
|
+
logger.debug(msg=f"Skip tag {tag:25} [#{tag_id}]")
|
|
121
|
+
return extra, {'resolution': resolution, 'resolutionunit': resolutionunit,
|
|
122
|
+
'software': software, 'photometric': photometric}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_tiff_dtype_count(value):
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
return 2, len(value) + 1 # ASCII string, (dtype=2), length + null terminator
|
|
128
|
+
if isinstance(value, (bytes, bytearray)):
|
|
129
|
+
return 1, len(value) # Binary data (dtype=1)
|
|
130
|
+
if isinstance(value, (list, tuple, np.ndarray)):
|
|
131
|
+
if isinstance(value, np.ndarray):
|
|
132
|
+
dtype = value.dtype # Array or sequence
|
|
133
|
+
else:
|
|
134
|
+
dtype = np.array(value).dtype # Map numpy dtype to TIFF dtype
|
|
135
|
+
if dtype == np.uint8:
|
|
136
|
+
return 1, len(value)
|
|
137
|
+
if dtype == np.uint16:
|
|
138
|
+
return 3, len(value)
|
|
139
|
+
if dtype == np.uint32:
|
|
140
|
+
return 4, len(value)
|
|
141
|
+
if dtype == np.float32:
|
|
142
|
+
return 11, len(value)
|
|
143
|
+
if dtype == np.float64:
|
|
144
|
+
return 12, len(value)
|
|
145
|
+
if isinstance(value, int):
|
|
146
|
+
if 0 <= value <= 65535:
|
|
147
|
+
return 3, 1 # uint16
|
|
148
|
+
return 4, 1 # uint32
|
|
149
|
+
if isinstance(value, float):
|
|
150
|
+
return 11, 1 # float64
|
|
151
|
+
return 2, len(str(value)) + 1 # Default for othre cases (ASCII string)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def add_exif_data_to_jpg_file(exif, in_filenama, out_filename, verbose=False):
|
|
155
|
+
logger = logging.getLogger(__name__)
|
|
156
|
+
if exif is None:
|
|
157
|
+
raise RuntimeError('No exif data provided.')
|
|
158
|
+
if verbose:
|
|
159
|
+
print_exif(exif)
|
|
160
|
+
xmp_data = extract_enclosed_data_for_jpg(exif[XMLPACKET], b'<x:xmpmeta', b'</x:xmpmeta>')
|
|
161
|
+
with Image.open(in_filenama) as image:
|
|
162
|
+
with io.BytesIO() as buffer:
|
|
163
|
+
image.save(buffer, format="JPEG", exif=exif.tobytes(), quality=100)
|
|
164
|
+
jpeg_data = buffer.getvalue()
|
|
165
|
+
if xmp_data is not None:
|
|
166
|
+
app1_marker_pos = jpeg_data.find(b'\xFF\xE1')
|
|
167
|
+
if app1_marker_pos == -1:
|
|
168
|
+
app1_marker_pos = len(jpeg_data) - 2
|
|
169
|
+
updated_data = (
|
|
170
|
+
jpeg_data[:app1_marker_pos] +
|
|
171
|
+
b'\xFF\xE1' + len(xmp_data).to_bytes(2, 'big') +
|
|
172
|
+
xmp_data + jpeg_data[app1_marker_pos:]
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
logger.warning("Copy: can't find XMLPacket in JPG EXIF data")
|
|
176
|
+
updated_data = jpeg_data
|
|
177
|
+
with open(out_filename, 'wb') as f:
|
|
178
|
+
f.write(updated_data)
|
|
179
|
+
return exif
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def create_xmp_from_exif(exif_data):
|
|
183
|
+
xmp_elements = []
|
|
184
|
+
if exif_data:
|
|
185
|
+
for tag_id, value in exif_data.items():
|
|
186
|
+
if isinstance(tag_id, int):
|
|
187
|
+
if tag_id == 270 and value: # ImageDescription
|
|
188
|
+
desc = value
|
|
189
|
+
if isinstance(desc, bytes):
|
|
190
|
+
desc = desc.decode('utf-8', errors='ignore')
|
|
191
|
+
xmp_elements.append(
|
|
192
|
+
f'<dc:description><rdf:Alt><rdf:li xml:lang="x-default">{desc}</rdf:li>'
|
|
193
|
+
'</rdf:Alt></dc:description>')
|
|
194
|
+
elif tag_id == 315 and value: # Artist
|
|
195
|
+
artist = value
|
|
196
|
+
if isinstance(artist, bytes):
|
|
197
|
+
artist = artist.decode('utf-8', errors='ignore')
|
|
198
|
+
xmp_elements.append(
|
|
199
|
+
f'<dc:creator><rdf:Seq><rdf:li>{artist}</rdf:li>'
|
|
200
|
+
'</rdf:Seq></dc:creator>')
|
|
201
|
+
elif tag_id == 33432 and value: # Copyright
|
|
202
|
+
copyright_tag = value
|
|
203
|
+
if isinstance(copyright_tag, bytes):
|
|
204
|
+
copyright_tag = copyright_tag.decode('utf-8', errors='ignore')
|
|
205
|
+
xmp_elements.append(
|
|
206
|
+
f'<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">{copyright_tag}</rdf:li>'
|
|
207
|
+
'</rdf:Alt></dc:rights>')
|
|
208
|
+
elif tag_id == 271 and value: # Make
|
|
209
|
+
make = value
|
|
210
|
+
if isinstance(make, bytes):
|
|
211
|
+
make = make.decode('utf-8', errors='ignore')
|
|
212
|
+
xmp_elements.append(f'<tiff:Make>{make}</tiff:Make>')
|
|
213
|
+
elif tag_id == 272 and value: # Model
|
|
214
|
+
model = value
|
|
215
|
+
if isinstance(model, bytes):
|
|
216
|
+
model = model.decode('utf-8', errors='ignore')
|
|
217
|
+
xmp_elements.append(f'<tiff:Model>{model}</tiff:Model>')
|
|
218
|
+
elif tag_id == 306 and value: # DateTime
|
|
219
|
+
datetime_val = value
|
|
220
|
+
if isinstance(datetime_val, bytes):
|
|
221
|
+
datetime_val = datetime_val.decode('utf-8', errors='ignore')
|
|
222
|
+
if ':' in datetime_val:
|
|
223
|
+
datetime_val = datetime_val.replace(':', '-', 2).replace(' ', 'T')
|
|
224
|
+
xmp_elements.append(f'<xmp:CreateDate>{datetime_val}</xmp:CreateDate>')
|
|
225
|
+
elif tag_id == 305 and value: # Software
|
|
226
|
+
software = value
|
|
227
|
+
if isinstance(software, bytes):
|
|
228
|
+
software = software.decode('utf-8', errors='ignore')
|
|
229
|
+
xmp_elements.append(f'<xmp:CreatorTool>{software}</xmp:CreatorTool>')
|
|
230
|
+
if xmp_elements:
|
|
231
|
+
xmp_content = '\n '.join(xmp_elements)
|
|
232
|
+
xmp_template = f"""<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
|
233
|
+
<x:xmpmeta xmlns:x='adobe:ns:meta/'
|
|
234
|
+
x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
|
|
235
|
+
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
|
|
236
|
+
<rdf:Description rdf:about=''
|
|
237
|
+
xmlns:dc='http://purl.org/dc/elements/1.1/'
|
|
238
|
+
xmlns:xmp='http://ns.adobe.com/xap/1.0/'
|
|
239
|
+
xmlns:tiff='http://ns.adobe.com/tiff/1.0/'
|
|
240
|
+
xmlns:exif='http://ns.adobe.com/exif/1.0/'>
|
|
241
|
+
{xmp_content}
|
|
242
|
+
</rdf:Description>
|
|
243
|
+
</rdf:RDF>
|
|
244
|
+
</x:xmpmeta>
|
|
245
|
+
<?xpacket end='w'?>"""
|
|
246
|
+
return xmp_template
|
|
247
|
+
return """<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
|
|
248
|
+
<x:xmpmeta xmlns:x='adobe:ns:meta/'
|
|
249
|
+
x:xmptk='Adobe XMP Core 5.6-c140 79.160451, 2017/05/06-01:08:21'>
|
|
250
|
+
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>
|
|
251
|
+
<rdf:Description rdf:about=''/>
|
|
252
|
+
</rdf:RDF>
|
|
253
|
+
</x:xmpmeta>
|
|
254
|
+
<?xpacket end='w'?>"""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def write_image_with_exif_data_png(exif, image, out_filename, verbose=False, color_order='auto'):
|
|
258
|
+
logger = logging.getLogger(__name__)
|
|
259
|
+
if isinstance(image, np.ndarray) and image.dtype == np.uint16:
|
|
260
|
+
if verbose:
|
|
261
|
+
logger.warning(msg="EXIF data not supported for 16-bit PNG format")
|
|
262
|
+
write_img(out_filename, image)
|
|
263
|
+
return
|
|
264
|
+
pil_image = _convert_to_pil_image(image, color_order, verbose, logger)
|
|
265
|
+
pnginfo, icc_profile = _prepare_png_metadata(exif, verbose, logger)
|
|
266
|
+
_save_png_with_metadata(pil_image, out_filename, pnginfo, icc_profile, verbose, logger)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _convert_to_pil_image(image, color_order, verbose, logger):
|
|
270
|
+
if isinstance(image, np.ndarray):
|
|
271
|
+
if len(image.shape) == 3 and image.shape[2] == 3:
|
|
272
|
+
if color_order in ['auto', 'bgr']:
|
|
273
|
+
image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
|
274
|
+
if verbose:
|
|
275
|
+
logger.info(msg="Converted BGR to RGB for PIL")
|
|
276
|
+
return Image.fromarray(image_rgb)
|
|
277
|
+
return Image.fromarray(image)
|
|
278
|
+
return image
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _prepare_png_metadata(exif, verbose, logger):
|
|
282
|
+
pnginfo = PngInfo()
|
|
283
|
+
icc_profile = None
|
|
284
|
+
xmp_data = _extract_xmp_data(exif, verbose, logger)
|
|
285
|
+
if xmp_data:
|
|
286
|
+
pnginfo.add_text("XML:com.adobe.xmp", xmp_data)
|
|
287
|
+
if verbose:
|
|
288
|
+
logger.info(msg="Added XMP data to PNG info")
|
|
289
|
+
_add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger)
|
|
290
|
+
icc_profile = _extract_icc_profile(exif, verbose, logger)
|
|
291
|
+
return pnginfo, icc_profile
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _extract_xmp_data(exif, verbose, logger):
|
|
295
|
+
for key, value in exif.items():
|
|
296
|
+
if isinstance(key, str) and ('xmp' in key.lower() or 'xml' in key.lower()):
|
|
297
|
+
if isinstance(value, bytes):
|
|
298
|
+
try:
|
|
299
|
+
xmp_data = value.decode('utf-8', errors='ignore')
|
|
300
|
+
if verbose:
|
|
301
|
+
logger.info(msg=f"Found existing XMP data in source: {key}")
|
|
302
|
+
return xmp_data
|
|
303
|
+
except Exception:
|
|
304
|
+
continue
|
|
305
|
+
elif isinstance(value, str):
|
|
306
|
+
if verbose:
|
|
307
|
+
logger.info(msg=f"Found existing XMP data in source: {key}")
|
|
308
|
+
return value
|
|
309
|
+
if verbose:
|
|
310
|
+
logger.info("Generated new XMP data from EXIF")
|
|
311
|
+
return create_xmp_from_exif(exif)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _add_exif_tags_to_pnginfo(exif, pnginfo, verbose, logger):
|
|
315
|
+
for tag_id, value in exif.items():
|
|
316
|
+
if value is None:
|
|
317
|
+
continue
|
|
318
|
+
if isinstance(tag_id, int):
|
|
319
|
+
_add_exif_tag(pnginfo, tag_id, value, verbose, logger)
|
|
320
|
+
elif isinstance(tag_id, str) and not tag_id.lower().startswith(('xmp', 'xml')):
|
|
321
|
+
_add_png_text_tag(pnginfo, tag_id, value, verbose, logger)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _add_exif_tag(pnginfo, tag_id, value, verbose, logger):
|
|
325
|
+
try:
|
|
326
|
+
tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}")
|
|
327
|
+
if isinstance(value, bytes) and len(value) > 1000:
|
|
328
|
+
return
|
|
329
|
+
if isinstance(value, (int, float, str)):
|
|
330
|
+
pnginfo.add_text(tag_name, str(value))
|
|
331
|
+
elif isinstance(value, bytes):
|
|
332
|
+
try:
|
|
333
|
+
decoded_value = value.decode('utf-8', errors='replace')
|
|
334
|
+
pnginfo.add_text(tag_name, decoded_value)
|
|
335
|
+
except Exception:
|
|
336
|
+
pass
|
|
337
|
+
elif hasattr(value, 'numerator'): # IFDRational
|
|
338
|
+
rational_str = f"{value.numerator}/{value.denominator}"
|
|
339
|
+
pnginfo.add_text(tag_name, rational_str)
|
|
340
|
+
else:
|
|
341
|
+
pnginfo.add_text(tag_name, str(value))
|
|
342
|
+
except Exception as e:
|
|
343
|
+
if verbose:
|
|
344
|
+
logger.warning(f"Could not store EXIF tag {tag_id}: {e}")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _add_png_text_tag(pnginfo, key, value, verbose, logger):
|
|
348
|
+
try:
|
|
349
|
+
clean_key = key[4:] if key.startswith('PNG_') else key
|
|
350
|
+
if 'icc' in clean_key.lower() or 'profile' in clean_key.lower():
|
|
351
|
+
return
|
|
352
|
+
if isinstance(value, bytes):
|
|
353
|
+
try:
|
|
354
|
+
decoded_value = value.decode('utf-8', errors='replace')
|
|
355
|
+
pnginfo.add_text(clean_key, decoded_value)
|
|
356
|
+
except Exception:
|
|
357
|
+
truncated_value = str(value)[:100] + "..."
|
|
358
|
+
pnginfo.add_text(clean_key, truncated_value)
|
|
359
|
+
else:
|
|
360
|
+
pnginfo.add_text(clean_key, str(value))
|
|
361
|
+
except Exception as e:
|
|
362
|
+
if verbose:
|
|
363
|
+
logger.warning(msg=f"Could not store PNG metadata {key}: {e}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _extract_icc_profile(exif, verbose, logger):
|
|
367
|
+
for key, value in exif.items():
|
|
368
|
+
if (isinstance(key, str) and
|
|
369
|
+
isinstance(value, bytes) and
|
|
370
|
+
('icc' in key.lower() or 'profile' in key.lower())):
|
|
371
|
+
if verbose:
|
|
372
|
+
logger.info(f"Found ICC profile: {key}")
|
|
373
|
+
return value
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _save_png_with_metadata(pil_image, out_filename, pnginfo, icc_profile, verbose, logger):
|
|
378
|
+
try:
|
|
379
|
+
save_args = {'format': 'PNG', 'pnginfo': pnginfo}
|
|
380
|
+
if icc_profile:
|
|
381
|
+
save_args['icc_profile'] = icc_profile
|
|
382
|
+
if verbose:
|
|
383
|
+
logger.info(msg="Saved PNG with ICC profile and metadata")
|
|
384
|
+
else:
|
|
385
|
+
if verbose:
|
|
386
|
+
logger.info(msg="Saved PNG without ICC profile but with metadata")
|
|
387
|
+
pil_image.save(out_filename, **save_args)
|
|
388
|
+
if verbose:
|
|
389
|
+
logger.info(msg=f"Successfully wrote PNG with metadata: {out_filename}")
|
|
390
|
+
except Exception as e:
|
|
391
|
+
if verbose:
|
|
392
|
+
logger.error(msg=f"Failed to write PNG with metadata: {e}")
|
|
393
|
+
logger.error(traceback.format_exc())
|
|
394
|
+
pil_image.save(out_filename, format='PNG')
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def write_image_with_exif_data(exif, image, out_filename, verbose=False, color_order='auto'):
|
|
398
|
+
if exif is None:
|
|
399
|
+
write_img(out_filename, image)
|
|
400
|
+
return None
|
|
401
|
+
if verbose:
|
|
402
|
+
print_exif(exif)
|
|
403
|
+
if extension_jpg(out_filename):
|
|
404
|
+
cv2.imwrite(out_filename, image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
|
|
405
|
+
add_exif_data_to_jpg_file(exif, out_filename, out_filename, verbose)
|
|
406
|
+
elif extension_tif(out_filename):
|
|
407
|
+
metadata = {"description": f"image generated with {constants.APP_STRING} package"}
|
|
408
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
|
|
409
|
+
tifffile.imwrite(out_filename, image, metadata=metadata, compression='adobe_deflate',
|
|
410
|
+
extratags=extra_tags, **exif_tags)
|
|
411
|
+
elif extension_png(out_filename):
|
|
412
|
+
write_image_with_exif_data_png(exif, image, out_filename, verbose, color_order=color_order)
|
|
413
|
+
return exif
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def save_exif_data(exif, in_filename, out_filename=None, verbose=False):
|
|
417
|
+
if out_filename is None:
|
|
418
|
+
out_filename = in_filename
|
|
419
|
+
if exif is None:
|
|
420
|
+
raise RuntimeError('No exif data provided.')
|
|
421
|
+
if verbose:
|
|
422
|
+
print_exif(exif)
|
|
423
|
+
if extension_tif(in_filename):
|
|
424
|
+
image_new = tifffile.imread(in_filename)
|
|
425
|
+
elif extension_jpg(in_filename):
|
|
426
|
+
image_new = Image.open(in_filename)
|
|
427
|
+
elif extension_png(in_filename):
|
|
428
|
+
image_new = cv2.imread(in_filename, cv2.IMREAD_UNCHANGED)
|
|
429
|
+
if extension_jpg(in_filename):
|
|
430
|
+
add_exif_data_to_jpg_file(exif, in_filename, out_filename, verbose)
|
|
431
|
+
elif extension_tif(in_filename):
|
|
432
|
+
metadata = {"description": f"image generated with {constants.APP_STRING} package"}
|
|
433
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(exif)
|
|
434
|
+
tifffile.imwrite(out_filename, image_new, metadata=metadata, compression='adobe_deflate',
|
|
435
|
+
extratags=extra_tags, **exif_tags)
|
|
436
|
+
elif extension_png(in_filename):
|
|
437
|
+
write_image_with_exif_data_png(exif, image_new, out_filename, verbose)
|
|
438
|
+
return exif
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def copy_exif_from_file_to_file(exif_filename, in_filename, out_filename=None, verbose=False):
|
|
442
|
+
if not os.path.isfile(exif_filename):
|
|
443
|
+
raise RuntimeError(f"File does not exist: {exif_filename}")
|
|
444
|
+
if not os.path.isfile(in_filename):
|
|
445
|
+
raise RuntimeError(f"File does not exist: {in_filename}")
|
|
446
|
+
exif = get_exif(exif_filename)
|
|
447
|
+
return save_exif_data(exif, in_filename, out_filename, verbose)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def exif_dict(exif, hide_xml=True):
|
|
451
|
+
if exif is None:
|
|
452
|
+
return None
|
|
453
|
+
exif_data = {}
|
|
454
|
+
for tag_id in exif:
|
|
455
|
+
tag = TAGS.get(tag_id, tag_id)
|
|
456
|
+
if tag_id == XMLPACKET and hide_xml:
|
|
457
|
+
data = "<<< XML data >>>"
|
|
458
|
+
elif tag_id in (IMAGERESOURCES, INTERCOLORPROFILE):
|
|
459
|
+
data = "<<< Photoshop data >>>"
|
|
460
|
+
elif tag_id == STRIPOFFSETS:
|
|
461
|
+
data = "<<< Strip offsets >>>"
|
|
462
|
+
elif tag_id == STRIPBYTECOUNTS:
|
|
463
|
+
data = "<<< Strip byte counts >>>"
|
|
464
|
+
else:
|
|
465
|
+
data = exif.get(tag_id) if hasattr(exif, 'get') else exif[tag_id]
|
|
466
|
+
if isinstance(data, bytes):
|
|
467
|
+
try:
|
|
468
|
+
data = data.decode()
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
exif_data[tag] = (tag_id, data)
|
|
472
|
+
return exif_data
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def print_exif(exif, hide_xml=True):
|
|
476
|
+
exif_data = exif_dict(exif, hide_xml)
|
|
477
|
+
if exif_data is None:
|
|
478
|
+
raise RuntimeError('Image has no exif data.')
|
|
479
|
+
logger = logging.getLogger(__name__)
|
|
480
|
+
for tag, (tag_id, data) in exif_data.items():
|
|
481
|
+
if isinstance(data, IFDRational):
|
|
482
|
+
data = f"{data.numerator}/{data.denominator}"
|
|
483
|
+
if isinstance(tag_id, int):
|
|
484
|
+
logger.info(msg=f"{tag:25} [#{tag_id:5d}]: {data}")
|
|
485
|
+
else:
|
|
486
|
+
logger.info(msg=f"{tag:25} [ {tag_id:20} ]: {str(data)[:100]}...")
|
|
@@ -13,7 +13,7 @@ from .. config.constants import constants
|
|
|
13
13
|
from .. config.config import config
|
|
14
14
|
from .. core.colors import color_str
|
|
15
15
|
from .. core.framework import TaskBase
|
|
16
|
-
from .utils import EXTENSIONS_TIF, EXTENSIONS_JPG, EXTENSIONS_PNG
|
|
16
|
+
from .utils import EXTENSIONS_TIF, EXTENSIONS_JPG, EXTENSIONS_PNG, EXTENSIONS_SUPPORTED
|
|
17
17
|
from .stack_framework import ImageSequenceManager
|
|
18
18
|
from .exif import exif_extra_tags_for_tif, get_exif
|
|
19
19
|
|
|
@@ -142,14 +142,16 @@ def write_multilayer_tiff_from_images(image_dict, output_file, exif_path='', cal
|
|
|
142
142
|
elif os.path.isdir(exif_path):
|
|
143
143
|
_dirpath, _, fnames = next(os.walk(exif_path))
|
|
144
144
|
fnames = [name for name in fnames
|
|
145
|
-
if os.path.splitext(name)[-1][1:].lower() in
|
|
146
|
-
|
|
145
|
+
if os.path.splitext(name)[-1][1:].lower() in EXTENSIONS_SUPPORTED]
|
|
146
|
+
file_path = os.path.join(exif_path, fnames[0])
|
|
147
|
+
extra_tags, exif_tags = exif_extra_tags_for_tif(get_exif(file_path))
|
|
148
|
+
extra_tags = [tag for tag in extra_tags if isinstance(tag[0], int)]
|
|
147
149
|
tiff_tags['extratags'] += extra_tags
|
|
148
150
|
tiff_tags = {**tiff_tags, **exif_tags}
|
|
149
151
|
if callbacks:
|
|
150
152
|
callback = callbacks.get('write_msg', None)
|
|
151
153
|
if callback:
|
|
152
|
-
callback(
|
|
154
|
+
callback(os.path.basename(output_file))
|
|
153
155
|
compression = 'adobe_deflate'
|
|
154
156
|
overlayed_images = overlay(
|
|
155
157
|
*((np.concatenate((image, np.expand_dims(transp, axis=-1)),
|