imdiff 0.4.6__tar.gz → 2.1.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.
- imdiff-2.1.0/PKG-INFO +148 -0
- imdiff-2.1.0/README.md +116 -0
- imdiff-2.1.0/imdiff/__init__.py +8 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff/__main__.py +2 -1
- imdiff-2.1.0/imdiff/_compat.py +19 -0
- imdiff-2.1.0/imdiff/_types.py +19 -0
- imdiff-2.1.0/imdiff/cli/__init__.py +1 -0
- imdiff-2.1.0/imdiff/cli/_gui_runtime.py +39 -0
- imdiff-2.1.0/imdiff/cli/dir_diff.py +87 -0
- imdiff-2.1.0/imdiff/cli/image_diff.py +81 -0
- imdiff-2.1.0/imdiff/cli/main.py +135 -0
- imdiff-2.1.0/imdiff/directory_comparator.py +74 -0
- imdiff-2.1.0/imdiff/gui/__init__.py +6 -0
- imdiff-2.1.0/imdiff/gui/_types.py +37 -0
- imdiff-2.1.0/imdiff/gui/dir_diff_main_window.py +190 -0
- imdiff-2.1.0/imdiff/gui/file_list_frame.py +562 -0
- imdiff-2.1.0/imdiff/gui/image_canvas.py +263 -0
- imdiff-2.1.0/imdiff/gui/image_diff_main_window.py +92 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff/gui/image_frame.py +175 -124
- imdiff-2.1.0/imdiff/gui/image_scaling.py +101 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff/gui/status_bar.py +40 -20
- imdiff-2.1.0/imdiff/gui/transient_menu.py +91 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff/gui/zoom_menu.py +31 -8
- imdiff-2.1.0/imdiff/image_comparator.py +266 -0
- imdiff-2.1.0/imdiff/list_files.py +100 -0
- imdiff-2.1.0/imdiff/util.py +97 -0
- imdiff-2.1.0/imdiff/version.py +4 -0
- imdiff-2.1.0/imdiff.egg-info/PKG-INFO +148 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff.egg-info/SOURCES.txt +14 -1
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff.egg-info/requires.txt +5 -1
- {imdiff-0.4.6 → imdiff-2.1.0}/pyproject.toml +43 -7
- imdiff-2.1.0/test/test_cli_diff.py +288 -0
- imdiff-2.1.0/test/test_cli_main.py +274 -0
- imdiff-2.1.0/test/test_directory_comparator.py +132 -0
- imdiff-2.1.0/test/test_file_list_frame.py +101 -0
- imdiff-2.1.0/test/test_image_comparator.py +375 -0
- imdiff-2.1.0/test/test_image_scaling.py +78 -0
- imdiff-2.1.0/test/test_list_files.py +134 -0
- imdiff-2.1.0/test/test_module_main.py +14 -0
- imdiff-2.1.0/test/test_util.py +57 -0
- imdiff-0.4.6/PKG-INFO +0 -31
- imdiff-0.4.6/README.md +0 -3
- imdiff-0.4.6/imdiff/__init__.py +0 -4
- imdiff-0.4.6/imdiff/cli/__init__.py +0 -0
- imdiff-0.4.6/imdiff/cli/dir_diff.py +0 -46
- imdiff-0.4.6/imdiff/cli/image_diff.py +0 -42
- imdiff-0.4.6/imdiff/cli/main.py +0 -58
- imdiff-0.4.6/imdiff/directory_comparator.py +0 -53
- imdiff-0.4.6/imdiff/gui/__init__.py +0 -2
- imdiff-0.4.6/imdiff/gui/dir_diff_main_window.py +0 -131
- imdiff-0.4.6/imdiff/gui/file_list_frame.py +0 -448
- imdiff-0.4.6/imdiff/gui/image_canvas.py +0 -185
- imdiff-0.4.6/imdiff/gui/image_diff_main_window.py +0 -54
- imdiff-0.4.6/imdiff/gui/image_scaling.py +0 -88
- imdiff-0.4.6/imdiff/gui/transient_menu.py +0 -67
- imdiff-0.4.6/imdiff/image_comparator.py +0 -166
- imdiff-0.4.6/imdiff/list_files.py +0 -64
- imdiff-0.4.6/imdiff/util.py +0 -74
- imdiff-0.4.6/imdiff/version.py +0 -2
- imdiff-0.4.6/imdiff.egg-info/PKG-INFO +0 -31
- {imdiff-0.4.6 → imdiff-2.1.0}/LICENSE +0 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff.egg-info/dependency_links.txt +0 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff.egg-info/entry_points.txt +0 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/imdiff.egg-info/top_level.txt +0 -0
- {imdiff-0.4.6 → imdiff-2.1.0}/setup.cfg +0 -0
imdiff-2.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imdiff
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: Compare image files in different directories
|
|
5
|
+
Author-email: "John T. Goetz" <theodore.goetz@gmail.com>
|
|
6
|
+
Project-URL: homepage, https://gitlab.com/johngoetz/imdiff
|
|
7
|
+
Keywords: image-diff,directory-comparison,visual-regression,golden-files,test-artifacts,qa
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics :: Viewers
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy
|
|
26
|
+
Requires-Dist: pillow
|
|
27
|
+
Requires-Dist: ttkbootstrap
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: ruff; extra == "dev"
|
|
30
|
+
Requires-Dist: basedpyright; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# ImDiff: Compare Directories of Images
|
|
34
|
+
|
|
35
|
+
ImDiff compares either two image files or two directory trees that contain image
|
|
36
|
+
files with matching relative paths. It is intended for workflows where generated
|
|
37
|
+
images need visual review as part of test development, golden-file updates, or
|
|
38
|
+
artifact triage.
|
|
39
|
+
|
|
40
|
+
The project exposes the same comparison engine through two interfaces:
|
|
41
|
+
|
|
42
|
+
- `python -m imdiff LEFT RIGHT` opens a Tk-based review window when the GUI
|
|
43
|
+
stack is available, or warns and falls back to summary mode when it is not.
|
|
44
|
+
- `python -m imdiff --summary LEFT RIGHT` prints a non-interactive summary and
|
|
45
|
+
exits with a non-zero status when differences are found without importing any
|
|
46
|
+
GUI-only modules.
|
|
47
|
+
|
|
48
|
+
When both arguments are directories, ImDiff walks the union of both directory
|
|
49
|
+
trees, pairs files by relative path, and classifies each entry as:
|
|
50
|
+
|
|
51
|
+
- `identical`: byte-identical files or text files with no unified diff output
|
|
52
|
+
- `similar`: images whose normalized RMSE is below the project threshold
|
|
53
|
+
- `different`: images or text files whose contents differ materially
|
|
54
|
+
- `different-size`: image pairs with different dimensions
|
|
55
|
+
- `missing` or `new`: a file exists on only one side
|
|
56
|
+
- `failed-to-load`: a file exists but Pillow could not decode it as an image
|
|
57
|
+
|
|
58
|
+
The interactive directory view combines a file tree, image panes, and file
|
|
59
|
+
operations so a reviewer can inspect left, right, and diff images, switch zoom
|
|
60
|
+
policies, and copy or delete files directly from the comparison session.
|
|
61
|
+
|
|
62
|
+
This utility requires that the Tk Python packages be installed on the system. Hint for
|
|
63
|
+
those running Ubuntu:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
apt install python3-pil python3-pil.imagetk python3-tk
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The application also depends on `numpy` and `ttkbootstrap` for image math and
|
|
70
|
+
themed Tk widgets.
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
Compare two image files:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
python -m imdiff path/to/expected.png path/to/actual.png
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Compare two directories and print a CI-friendly summary:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python -m imdiff --summary tests/baseline-images tests/generated-images
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
By default the normalized RMSE is measured over the whole image, which tolerates
|
|
87
|
+
the many tiny GPU/driver rendering variations that show up across large images.
|
|
88
|
+
To instead catch small but concentrated differences, select a sliding-window
|
|
89
|
+
size with `--window`; the reported score becomes the worst-scoring window of that
|
|
90
|
+
size:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python -m imdiff --window medium tests/baseline-images tests/generated-images
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The available window sizes are `global` (the default, whole image), `small`
|
|
97
|
+
(32x32), `medium` (64x64), `large` (128x128), and `xlarge` (256x256). You can
|
|
98
|
+
also pass an explicit pixel size as `WIDTH,HEIGHT`, for example
|
|
99
|
+
`--window 1024,1024`, when none of the presets fit. A window larger than the
|
|
100
|
+
image is clamped to the image size. The flag applies to both the interactive
|
|
101
|
+
windows and `--summary` mode.
|
|
102
|
+
|
|
103
|
+
When the GUI cannot be imported or Tk cannot start, the default interactive
|
|
104
|
+
command warns on `stderr` and continues in the same summary mode used by
|
|
105
|
+
`--summary`. This makes headless CI environments safe without changing the CLI
|
|
106
|
+
command line.
|
|
107
|
+
|
|
108
|
+
## Programmatic comparison
|
|
109
|
+
|
|
110
|
+
The same comparison core is available to other Python code, such as test
|
|
111
|
+
harnesses that compare a captured screenshot against a stored reference:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from imdiff import ImageComparator, RmseWindow
|
|
115
|
+
|
|
116
|
+
comparator = ImageComparator(reference_png, actual_png, RmseWindow.Medium)
|
|
117
|
+
|
|
118
|
+
status = comparator.diff_info # 'identical', 'missing', 'different-size', ...
|
|
119
|
+
if isinstance(status, float): # a same-size pair: status is the normalized RMSE
|
|
120
|
+
assert status <= 0.03, f'normalized RMSE {status:g} exceeded threshold'
|
|
121
|
+
|
|
122
|
+
comparator.diff # grayscale difference image (PIL.Image)
|
|
123
|
+
comparator.high_contrast_diff # histogram-stretched difference image
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`diff_info` returns a symbolic status string for presence, size, and load
|
|
127
|
+
problems, or the normalized RMSE (range `[0, 1]`) when both images decode at the
|
|
128
|
+
same size. The metric honors the `window` passed to the constructor: `Global`
|
|
129
|
+
scores the whole image, while any other `RmseWindow` reports the largest score
|
|
130
|
+
found across every window position of that size. The `window` may also be an
|
|
131
|
+
explicit `(width, height)` tuple instead of a preset. A window larger than the
|
|
132
|
+
image is clamped to the image size. Decoded images and derived results are
|
|
133
|
+
cached on first use and recomputed after `copy_left_to_right`,
|
|
134
|
+
`copy_right_to_left`, `delete_files`, or an explicit `clear`.
|
|
135
|
+
|
|
136
|
+
The `normalized_rmse(left_data, right_data, window)` function is also exported
|
|
137
|
+
for callers that already hold equally sized RGBA arrays.
|
|
138
|
+
|
|
139
|
+
## Repository Layout
|
|
140
|
+
|
|
141
|
+
- `imdiff/image_comparator.py` is the comparison core: the `RmseWindow` sizes,
|
|
142
|
+
the `normalized_rmse` metric, and the lazy `ImageComparator` model shared by
|
|
143
|
+
the CLI and GUI.
|
|
144
|
+
- `imdiff/list_files.py` pairs files across directory trees.
|
|
145
|
+
- `imdiff/cli/` contains the command-line entry points and summary printing.
|
|
146
|
+
- `imdiff/gui/` contains the Tk windows, canvases, menus, and directory browser.
|
|
147
|
+
- `developing/architecture.md` describes the component relationships in more
|
|
148
|
+
detail.
|
imdiff-2.1.0/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# ImDiff: Compare Directories of Images
|
|
2
|
+
|
|
3
|
+
ImDiff compares either two image files or two directory trees that contain image
|
|
4
|
+
files with matching relative paths. It is intended for workflows where generated
|
|
5
|
+
images need visual review as part of test development, golden-file updates, or
|
|
6
|
+
artifact triage.
|
|
7
|
+
|
|
8
|
+
The project exposes the same comparison engine through two interfaces:
|
|
9
|
+
|
|
10
|
+
- `python -m imdiff LEFT RIGHT` opens a Tk-based review window when the GUI
|
|
11
|
+
stack is available, or warns and falls back to summary mode when it is not.
|
|
12
|
+
- `python -m imdiff --summary LEFT RIGHT` prints a non-interactive summary and
|
|
13
|
+
exits with a non-zero status when differences are found without importing any
|
|
14
|
+
GUI-only modules.
|
|
15
|
+
|
|
16
|
+
When both arguments are directories, ImDiff walks the union of both directory
|
|
17
|
+
trees, pairs files by relative path, and classifies each entry as:
|
|
18
|
+
|
|
19
|
+
- `identical`: byte-identical files or text files with no unified diff output
|
|
20
|
+
- `similar`: images whose normalized RMSE is below the project threshold
|
|
21
|
+
- `different`: images or text files whose contents differ materially
|
|
22
|
+
- `different-size`: image pairs with different dimensions
|
|
23
|
+
- `missing` or `new`: a file exists on only one side
|
|
24
|
+
- `failed-to-load`: a file exists but Pillow could not decode it as an image
|
|
25
|
+
|
|
26
|
+
The interactive directory view combines a file tree, image panes, and file
|
|
27
|
+
operations so a reviewer can inspect left, right, and diff images, switch zoom
|
|
28
|
+
policies, and copy or delete files directly from the comparison session.
|
|
29
|
+
|
|
30
|
+
This utility requires that the Tk Python packages be installed on the system. Hint for
|
|
31
|
+
those running Ubuntu:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
apt install python3-pil python3-pil.imagetk python3-tk
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The application also depends on `numpy` and `ttkbootstrap` for image math and
|
|
38
|
+
themed Tk widgets.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Compare two image files:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python -m imdiff path/to/expected.png path/to/actual.png
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Compare two directories and print a CI-friendly summary:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python -m imdiff --summary tests/baseline-images tests/generated-images
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
By default the normalized RMSE is measured over the whole image, which tolerates
|
|
55
|
+
the many tiny GPU/driver rendering variations that show up across large images.
|
|
56
|
+
To instead catch small but concentrated differences, select a sliding-window
|
|
57
|
+
size with `--window`; the reported score becomes the worst-scoring window of that
|
|
58
|
+
size:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python -m imdiff --window medium tests/baseline-images tests/generated-images
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The available window sizes are `global` (the default, whole image), `small`
|
|
65
|
+
(32x32), `medium` (64x64), `large` (128x128), and `xlarge` (256x256). You can
|
|
66
|
+
also pass an explicit pixel size as `WIDTH,HEIGHT`, for example
|
|
67
|
+
`--window 1024,1024`, when none of the presets fit. A window larger than the
|
|
68
|
+
image is clamped to the image size. The flag applies to both the interactive
|
|
69
|
+
windows and `--summary` mode.
|
|
70
|
+
|
|
71
|
+
When the GUI cannot be imported or Tk cannot start, the default interactive
|
|
72
|
+
command warns on `stderr` and continues in the same summary mode used by
|
|
73
|
+
`--summary`. This makes headless CI environments safe without changing the CLI
|
|
74
|
+
command line.
|
|
75
|
+
|
|
76
|
+
## Programmatic comparison
|
|
77
|
+
|
|
78
|
+
The same comparison core is available to other Python code, such as test
|
|
79
|
+
harnesses that compare a captured screenshot against a stored reference:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from imdiff import ImageComparator, RmseWindow
|
|
83
|
+
|
|
84
|
+
comparator = ImageComparator(reference_png, actual_png, RmseWindow.Medium)
|
|
85
|
+
|
|
86
|
+
status = comparator.diff_info # 'identical', 'missing', 'different-size', ...
|
|
87
|
+
if isinstance(status, float): # a same-size pair: status is the normalized RMSE
|
|
88
|
+
assert status <= 0.03, f'normalized RMSE {status:g} exceeded threshold'
|
|
89
|
+
|
|
90
|
+
comparator.diff # grayscale difference image (PIL.Image)
|
|
91
|
+
comparator.high_contrast_diff # histogram-stretched difference image
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`diff_info` returns a symbolic status string for presence, size, and load
|
|
95
|
+
problems, or the normalized RMSE (range `[0, 1]`) when both images decode at the
|
|
96
|
+
same size. The metric honors the `window` passed to the constructor: `Global`
|
|
97
|
+
scores the whole image, while any other `RmseWindow` reports the largest score
|
|
98
|
+
found across every window position of that size. The `window` may also be an
|
|
99
|
+
explicit `(width, height)` tuple instead of a preset. A window larger than the
|
|
100
|
+
image is clamped to the image size. Decoded images and derived results are
|
|
101
|
+
cached on first use and recomputed after `copy_left_to_right`,
|
|
102
|
+
`copy_right_to_left`, `delete_files`, or an explicit `clear`.
|
|
103
|
+
|
|
104
|
+
The `normalized_rmse(left_data, right_data, window)` function is also exported
|
|
105
|
+
for callers that already hold equally sized RGBA arrays.
|
|
106
|
+
|
|
107
|
+
## Repository Layout
|
|
108
|
+
|
|
109
|
+
- `imdiff/image_comparator.py` is the comparison core: the `RmseWindow` sizes,
|
|
110
|
+
the `normalized_rmse` metric, and the lazy `ImageComparator` model shared by
|
|
111
|
+
the CLI and GUI.
|
|
112
|
+
- `imdiff/list_files.py` pairs files across directory trees.
|
|
113
|
+
- `imdiff/cli/` contains the command-line entry points and summary printing.
|
|
114
|
+
- `imdiff/gui/` contains the Tk windows, canvases, menus, and directory browser.
|
|
115
|
+
- `developing/architecture.md` describes the component relationships in more
|
|
116
|
+
detail.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Public package surface for embedding imdiff in other Python code."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportUnusedImport=none
|
|
4
|
+
# ruff: noqa: F401
|
|
5
|
+
|
|
6
|
+
from .directory_comparator import dir_diff
|
|
7
|
+
from .image_comparator import ImageComparator, RmseWindow, Window, normalized_rmse
|
|
8
|
+
from .version import __version__, version_info
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Compatibility helpers for typing features that are optional at runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
F = TypeVar('F', bound=Callable[..., object])
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
else:
|
|
13
|
+
|
|
14
|
+
def override(method: F) -> F:
|
|
15
|
+
"""Preserve the decorator shape when `typing_extensions` is unavailable."""
|
|
16
|
+
return method
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ['override']
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared type aliases for the comparison layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
from os import PathLike
|
|
7
|
+
from typing import Literal, Union
|
|
8
|
+
|
|
9
|
+
PathInput = Union[str, PathLike[str], pathlib.Path]
|
|
10
|
+
DiffStatus = Literal[
|
|
11
|
+
'identical',
|
|
12
|
+
'different-size',
|
|
13
|
+
'missing',
|
|
14
|
+
'new',
|
|
15
|
+
'failed-to-load',
|
|
16
|
+
'not-found',
|
|
17
|
+
]
|
|
18
|
+
DiffInfo = Union[DiffStatus, float]
|
|
19
|
+
FileEntry = tuple[str, pathlib.Path, pathlib.Path]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI package for file and directory entry points."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Helpers for lazy GUI startup and headless fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GuiUnavailableError(RuntimeError):
|
|
7
|
+
"""Raised when GUI support is unavailable but summary mode can continue."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_gui_warning(error: GuiUnavailableError) -> str:
|
|
11
|
+
"""Render a concise warning for stderr when falling back to summary mode."""
|
|
12
|
+
return f'warning: GUI unavailable ({error}); running in summary mode'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_gui_import_error(error: BaseException) -> GuiUnavailableError | None:
|
|
16
|
+
"""Convert expected GUI import failures into a fallback-friendly error."""
|
|
17
|
+
if isinstance(error, ModuleNotFoundError):
|
|
18
|
+
module_name = error.name or str(error)
|
|
19
|
+
return GuiUnavailableError(f'missing Python module {module_name}')
|
|
20
|
+
|
|
21
|
+
if isinstance(error, ImportError):
|
|
22
|
+
module_name = getattr(error, 'name', None)
|
|
23
|
+
if module_name:
|
|
24
|
+
return GuiUnavailableError(f'failed to import Python module {module_name}')
|
|
25
|
+
return GuiUnavailableError(str(error))
|
|
26
|
+
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_gui_startup_error(error: BaseException) -> GuiUnavailableError | None:
|
|
31
|
+
"""Convert expected Tk startup failures into a fallback-friendly error."""
|
|
32
|
+
error_type = type(error)
|
|
33
|
+
if error_type.__name__ == 'TclError' and error_type.__module__ in {
|
|
34
|
+
'tkinter',
|
|
35
|
+
'_tkinter',
|
|
36
|
+
}:
|
|
37
|
+
return GuiUnavailableError(f'Tk startup failed: {error}')
|
|
38
|
+
|
|
39
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""CLI adapters for directory comparison modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import importlib
|
|
7
|
+
import pathlib
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from ..image_comparator import ImageComparator, RmseWindow, Window
|
|
11
|
+
from ..list_files import is_image, list_files
|
|
12
|
+
from ._gui_runtime import (
|
|
13
|
+
normalize_gui_import_error,
|
|
14
|
+
normalize_gui_startup_error,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def app(
|
|
19
|
+
leftdir: Optional[pathlib.Path] = None,
|
|
20
|
+
rightdir: Optional[pathlib.Path] = None,
|
|
21
|
+
window: Window = RmseWindow.Global,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Open the directory comparison window for the requested roots."""
|
|
24
|
+
try:
|
|
25
|
+
gui_module = importlib.import_module('imdiff.gui')
|
|
26
|
+
DirDiffMainWindow = gui_module.DirDiffMainWindow
|
|
27
|
+
except (ImportError, ModuleNotFoundError) as error:
|
|
28
|
+
gui_error = normalize_gui_import_error(error)
|
|
29
|
+
assert gui_error is not None
|
|
30
|
+
raise gui_error from error
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
win = DirDiffMainWindow(window=window)
|
|
34
|
+
except Exception as error:
|
|
35
|
+
gui_error = normalize_gui_startup_error(error)
|
|
36
|
+
if gui_error is not None:
|
|
37
|
+
raise gui_error from error
|
|
38
|
+
raise
|
|
39
|
+
|
|
40
|
+
win.load(leftdir, rightdir)
|
|
41
|
+
win.mainloop()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_diff(
|
|
45
|
+
leftdir: pathlib.Path,
|
|
46
|
+
rightdir: pathlib.Path,
|
|
47
|
+
window: Window = RmseWindow.Global,
|
|
48
|
+
) -> int:
|
|
49
|
+
"""Print a summary of directory differences and return the diff count."""
|
|
50
|
+
ndiffs = 0
|
|
51
|
+
assert leftdir.is_dir() and rightdir.is_dir(), 'Items must both be directories'
|
|
52
|
+
|
|
53
|
+
rmse_threshold = 0.02
|
|
54
|
+
|
|
55
|
+
for subpath, left, right in list_files(leftdir, rightdir):
|
|
56
|
+
# Summary mode reuses the same comparison rules as the GUI so CI output
|
|
57
|
+
# matches what a developer will see when opening the same directories.
|
|
58
|
+
if not left.is_file():
|
|
59
|
+
ndiffs += 1
|
|
60
|
+
print(f'Only in {right.parent}: {subpath}')
|
|
61
|
+
elif not right.is_file():
|
|
62
|
+
ndiffs += 1
|
|
63
|
+
print(f'Only in {left.parent}: {subpath}')
|
|
64
|
+
elif is_image(left) and is_image(right):
|
|
65
|
+
icmp = ImageComparator(left, right, window)
|
|
66
|
+
diff_info = icmp.diff_info
|
|
67
|
+
if diff_info != 'identical':
|
|
68
|
+
if isinstance(diff_info, str):
|
|
69
|
+
ndiffs += 1
|
|
70
|
+
print(f'{diff_info} (as image): {subpath}')
|
|
71
|
+
elif diff_info > rmse_threshold:
|
|
72
|
+
ndiffs += 1
|
|
73
|
+
print(f'image difference NRMSE {diff_info:.4f}: {subpath}')
|
|
74
|
+
else:
|
|
75
|
+
diff_output = list(
|
|
76
|
+
difflib.unified_diff(
|
|
77
|
+
left.read_text().splitlines(),
|
|
78
|
+
right.read_text().splitlines(),
|
|
79
|
+
fromfile=str(left),
|
|
80
|
+
tofile=str(right),
|
|
81
|
+
lineterm='',
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if diff_output:
|
|
85
|
+
ndiffs += 1
|
|
86
|
+
print('\n'.join(diff_output))
|
|
87
|
+
return ndiffs
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""CLI adapters for single-image comparison modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import importlib
|
|
7
|
+
import pathlib
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from ..image_comparator import ImageComparator, RmseWindow, Window
|
|
11
|
+
from ..list_files import is_image
|
|
12
|
+
from ._gui_runtime import (
|
|
13
|
+
normalize_gui_import_error,
|
|
14
|
+
normalize_gui_startup_error,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def app(
|
|
19
|
+
left: Optional[pathlib.Path] = None,
|
|
20
|
+
right: Optional[pathlib.Path] = None,
|
|
21
|
+
window: Window = RmseWindow.Global,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Open the single-image comparison window for the requested files."""
|
|
24
|
+
try:
|
|
25
|
+
gui_module = importlib.import_module('imdiff.gui')
|
|
26
|
+
ImageDiffMainWindow = gui_module.ImageDiffMainWindow
|
|
27
|
+
except (ImportError, ModuleNotFoundError) as error:
|
|
28
|
+
gui_error = normalize_gui_import_error(error)
|
|
29
|
+
assert gui_error is not None
|
|
30
|
+
raise gui_error from error
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
win = ImageDiffMainWindow(window=window)
|
|
34
|
+
except Exception as error:
|
|
35
|
+
gui_error = normalize_gui_startup_error(error)
|
|
36
|
+
if gui_error is not None:
|
|
37
|
+
raise gui_error from error
|
|
38
|
+
raise
|
|
39
|
+
|
|
40
|
+
win.load(left, right)
|
|
41
|
+
win.mainloop()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_diff(
|
|
45
|
+
left: pathlib.Path,
|
|
46
|
+
right: pathlib.Path,
|
|
47
|
+
window: Window = RmseWindow.Global,
|
|
48
|
+
) -> int:
|
|
49
|
+
"""Print a summary of a single file comparison and return the diff count."""
|
|
50
|
+
ndiffs = 0
|
|
51
|
+
assert left.is_file() and right.is_file(), 'Items must both be files'
|
|
52
|
+
|
|
53
|
+
rmse_threshold = 0.02
|
|
54
|
+
if is_image(left) and is_image(right):
|
|
55
|
+
icmp = ImageComparator(left, right, window)
|
|
56
|
+
diff_info = icmp.diff_info
|
|
57
|
+
if diff_info != 'identical':
|
|
58
|
+
ndiffs += 1
|
|
59
|
+
if diff_info == 'missing':
|
|
60
|
+
print(f'Only in {left.parent}: {left.name}')
|
|
61
|
+
elif diff_info == 'new':
|
|
62
|
+
print(f'Only in {right.parent}: {right.name}')
|
|
63
|
+
elif isinstance(diff_info, str):
|
|
64
|
+
print(f'Images {left} and {right} differ ({diff_info})')
|
|
65
|
+
elif diff_info > rmse_threshold:
|
|
66
|
+
print(f'Images {left} and {right} differ NRMSE: {diff_info}')
|
|
67
|
+
else:
|
|
68
|
+
diff_output = list(
|
|
69
|
+
difflib.unified_diff(
|
|
70
|
+
left.read_text().splitlines(),
|
|
71
|
+
right.read_text().splitlines(),
|
|
72
|
+
fromfile=str(left),
|
|
73
|
+
tofile=str(right),
|
|
74
|
+
lineterm='',
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
if diff_output:
|
|
78
|
+
ndiffs += 1
|
|
79
|
+
print('\n'.join(diff_output))
|
|
80
|
+
|
|
81
|
+
return ndiffs
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Command-line dispatch for summary and GUI workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from ..image_comparator import RmseWindow, Window
|
|
11
|
+
from ..version import __version__
|
|
12
|
+
from . import dir_diff, image_diff
|
|
13
|
+
from ._gui_runtime import GuiUnavailableError, format_gui_warning
|
|
14
|
+
|
|
15
|
+
# CLI tokens map to the preset window sizes; the keys are what users type.
|
|
16
|
+
WINDOW_CHOICES = {window.name.lower(): window for window in RmseWindow}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ParsedArgs(argparse.Namespace):
|
|
20
|
+
"""Typed namespace so downstream code can treat parsed paths precisely."""
|
|
21
|
+
|
|
22
|
+
summary: bool = False
|
|
23
|
+
window: Window = RmseWindow.Global
|
|
24
|
+
left: pathlib.Path = pathlib.Path('.')
|
|
25
|
+
right: pathlib.Path = pathlib.Path('.')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_window(value: str) -> Window:
|
|
29
|
+
"""Resolve a ``--window`` token to a preset or an explicit pixel size.
|
|
30
|
+
|
|
31
|
+
Accepts a preset name (``global``, ``small``, ...) or two positive integers
|
|
32
|
+
such as ``1024,1024`` for an arbitrary window. Anything else is reported as
|
|
33
|
+
an argparse error so the user sees the valid forms.
|
|
34
|
+
"""
|
|
35
|
+
name = value.strip().lower()
|
|
36
|
+
if name in WINDOW_CHOICES:
|
|
37
|
+
return WINDOW_CHOICES[name]
|
|
38
|
+
|
|
39
|
+
parts = value.split(',')
|
|
40
|
+
if len(parts) == 2:
|
|
41
|
+
try:
|
|
42
|
+
width, height = (int(part) for part in parts)
|
|
43
|
+
except ValueError:
|
|
44
|
+
width = height = -1
|
|
45
|
+
if width > 0 and height > 0:
|
|
46
|
+
return (width, height)
|
|
47
|
+
|
|
48
|
+
presets = ', '.join(WINDOW_CHOICES)
|
|
49
|
+
raise argparse.ArgumentTypeError(
|
|
50
|
+
f'invalid window {value!r}; expected one of {presets} '
|
|
51
|
+
+ 'or two positive integers like 1024,1024'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_args() -> ParsedArgs:
|
|
56
|
+
"""Parse the shared CLI flags for file and directory comparison modes."""
|
|
57
|
+
parser = argparse.ArgumentParser(
|
|
58
|
+
prog='imdiff',
|
|
59
|
+
description='Compare images one by one or directory by directory',
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
'--version',
|
|
63
|
+
action='version',
|
|
64
|
+
version=f'%(prog)s {__version__}',
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
'--summary',
|
|
68
|
+
action='store_true',
|
|
69
|
+
help=(
|
|
70
|
+
'Print diff result and exit. Disables the GUI. '
|
|
71
|
+
'Exits with non-zero if there are differences.'
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
'--window',
|
|
76
|
+
type=parse_window,
|
|
77
|
+
default=RmseWindow.Global,
|
|
78
|
+
metavar='{global,small,medium,large,xlarge | WIDTH,HEIGHT}',
|
|
79
|
+
help=(
|
|
80
|
+
'Normalized RMSE window. "global" scores the whole image; a preset '
|
|
81
|
+
'or an explicit WIDTH,HEIGHT (e.g. 1024,1024) reports the '
|
|
82
|
+
'worst-scoring window of that size.'
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
'left',
|
|
87
|
+
default='.',
|
|
88
|
+
type=pathlib.Path,
|
|
89
|
+
help='Image or directory of images to compare.',
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
'right',
|
|
93
|
+
default='.',
|
|
94
|
+
type=pathlib.Path,
|
|
95
|
+
help='Image or directory of images to compare.',
|
|
96
|
+
)
|
|
97
|
+
return parser.parse_args(namespace=ParsedArgs())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> Optional[int]:
|
|
101
|
+
"""Dispatch to GUI or summary workflows, with summary fallback for headless use."""
|
|
102
|
+
args = parse_args()
|
|
103
|
+
window = args.window
|
|
104
|
+
if args.left.is_dir():
|
|
105
|
+
if not args.right.is_dir():
|
|
106
|
+
raise NotADirectoryError(
|
|
107
|
+
'Paths must be either both image files or both directories'
|
|
108
|
+
)
|
|
109
|
+
if args.summary:
|
|
110
|
+
ndiffs = dir_diff.print_diff(args.left, args.right, window)
|
|
111
|
+
return int(ndiffs > 0)
|
|
112
|
+
try:
|
|
113
|
+
dir_diff.app(args.left, args.right, window)
|
|
114
|
+
return None
|
|
115
|
+
except GuiUnavailableError as error:
|
|
116
|
+
print(format_gui_warning(error), file=sys.stderr)
|
|
117
|
+
ndiffs = dir_diff.print_diff(args.left, args.right, window)
|
|
118
|
+
return int(ndiffs > 0)
|
|
119
|
+
|
|
120
|
+
if args.left.is_file():
|
|
121
|
+
assert args.right.is_file(), (
|
|
122
|
+
'Paths must be either both image files or both directories'
|
|
123
|
+
)
|
|
124
|
+
if args.summary:
|
|
125
|
+
ndiffs = image_diff.print_diff(args.left, args.right, window)
|
|
126
|
+
return int(ndiffs > 0)
|
|
127
|
+
try:
|
|
128
|
+
image_diff.app(args.left, args.right, window)
|
|
129
|
+
return None
|
|
130
|
+
except GuiUnavailableError as error:
|
|
131
|
+
print(format_gui_warning(error), file=sys.stderr)
|
|
132
|
+
ndiffs = image_diff.print_diff(args.left, args.right, window)
|
|
133
|
+
return int(ndiffs > 0)
|
|
134
|
+
|
|
135
|
+
raise OSError(f'{args.left} is not a file or directory')
|