imdiff 0.2.0__tar.gz → 0.2.2__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-0.2.0 → imdiff-0.2.2}/PKG-INFO +4 -1
- imdiff-0.2.2/imdiff/__main__.py +7 -0
- imdiff-0.2.2/imdiff/cli/dir_diff.py +45 -0
- imdiff-0.2.2/imdiff/cli/image_diff.py +42 -0
- imdiff-0.2.2/imdiff/cli/main.py +55 -0
- imdiff-0.2.2/imdiff/gui/__init__.py +2 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/dir_diff_main_window.py +26 -31
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/file_list_frame.py +91 -43
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/image_canvas.py +30 -10
- imdiff-0.2.2/imdiff/gui/image_diff_main_window.py +54 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/image_frame.py +81 -32
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/image_scaling.py +8 -10
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/status_bar.py +21 -5
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/transient_menu.py +4 -4
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/gui/zoom_menu.py +44 -9
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/image_comparator.py +9 -7
- imdiff-0.2.2/imdiff/list_files.py +57 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/util.py +21 -10
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/version.py +1 -1
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff.egg-info/PKG-INFO +4 -1
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff.egg-info/requires.txt +1 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/pyproject.toml +1 -0
- imdiff-0.2.0/imdiff/__main__.py +0 -3
- imdiff-0.2.0/imdiff/cli/dir_diff.py +0 -7
- imdiff-0.2.0/imdiff/cli/image_diff.py +0 -4
- imdiff-0.2.0/imdiff/cli/main.py +0 -25
- imdiff-0.2.0/imdiff/gui/__init__.py +0 -2
- imdiff-0.2.0/imdiff/gui/image_diff_main_window.py +0 -0
- imdiff-0.2.0/imdiff/list_files.py +0 -37
- {imdiff-0.2.0 → imdiff-0.2.2}/LICENSE +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/README.md +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/__init__.py +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff/cli/__init__.py +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff.egg-info/SOURCES.txt +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff.egg-info/dependency_links.txt +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff.egg-info/entry_points.txt +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/imdiff.egg-info/top_level.txt +0 -0
- {imdiff-0.2.0 → imdiff-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: imdiff
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Compare image files in different directories
|
|
5
5
|
Author-email: "John T. Goetz" <theodore.goetz@gmail.com>
|
|
6
6
|
Project-URL: homepage, https://gitlab.com/johngoetz/imdiff
|
|
@@ -21,6 +21,9 @@ Classifier: Topic :: Utilities
|
|
|
21
21
|
Requires-Python: >=3.8
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
License-File: LICENSE
|
|
24
|
+
Requires-Dist: ttkbootstrap
|
|
25
|
+
Requires-Dist: numpy
|
|
26
|
+
Requires-Dist: pillow
|
|
24
27
|
|
|
25
28
|
# ImDiff: Compare Directories of Images
|
|
26
29
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
|
|
3
|
+
from ..list_files import is_image, list_files
|
|
4
|
+
from ..image_comparator import ImageComparator
|
|
5
|
+
from ..gui import DirDiffMainWindow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def app(leftdir=None, rightdir=None):
|
|
9
|
+
win = DirDiffMainWindow()
|
|
10
|
+
win.load(leftdir, rightdir)
|
|
11
|
+
win.mainloop()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def print_diff(leftdir=None, rightdir=None):
|
|
15
|
+
ndiffs = 0
|
|
16
|
+
assert leftdir.is_dir() and rightdir.is_dir(), 'Items must both be directories'
|
|
17
|
+
|
|
18
|
+
rmse_threshold = 0.02
|
|
19
|
+
|
|
20
|
+
for subpath, left, right in list_files(leftdir, rightdir):
|
|
21
|
+
if not left.is_file():
|
|
22
|
+
ndiffs += 1
|
|
23
|
+
print(f'Only in {right.parent}: {subpath}')
|
|
24
|
+
elif not right.is_file():
|
|
25
|
+
ndiffs += 1
|
|
26
|
+
print(f'Only in {left.parent}: {subpath}')
|
|
27
|
+
elif is_image(left) and is_image(right):
|
|
28
|
+
icmp = ImageComparator(left, right)
|
|
29
|
+
if icmp.diff_info != 'identical':
|
|
30
|
+
ndiffs += 1
|
|
31
|
+
if isinstance(icmp.diff_info, str):
|
|
32
|
+
print(f'{icmp.diff_info} (as image): {subpath}')
|
|
33
|
+
elif icmp.diff_info > rmse_threshold:
|
|
34
|
+
print(f'image difference NRMSE {icmp.diff_info:.4f}: {subpath}')
|
|
35
|
+
else:
|
|
36
|
+
diff_output = difflib.unified_diff(
|
|
37
|
+
left.read_text().splitlines(),
|
|
38
|
+
right.read_text().splitlines(),
|
|
39
|
+
fromfile=str(left),
|
|
40
|
+
tofile=str(right),
|
|
41
|
+
lineterm='')
|
|
42
|
+
if diff_output:
|
|
43
|
+
ndiffs += 1
|
|
44
|
+
print('\n'.join(diff_output))
|
|
45
|
+
return ndiffs
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
|
|
3
|
+
from ..list_files import is_image
|
|
4
|
+
from ..image_comparator import ImageComparator
|
|
5
|
+
from ..gui import ImageDiffMainWindow
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def app(left=None, right=None):
|
|
9
|
+
win = ImageDiffMainWindow()
|
|
10
|
+
win.load(left, right)
|
|
11
|
+
win.mainloop()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def print_diff(left, right):
|
|
15
|
+
ndiffs = 0
|
|
16
|
+
assert left.is_file() and right.is_file(), 'Items must both be files'
|
|
17
|
+
|
|
18
|
+
rmse_threshold = 0.02
|
|
19
|
+
if is_image(left) and is_image(right):
|
|
20
|
+
icmp = ImageComparator(left, right)
|
|
21
|
+
if icmp.diff_info != 'identical':
|
|
22
|
+
ndiffs += 1
|
|
23
|
+
if icmp.diff_info == 'missing':
|
|
24
|
+
print(f'Only in {left.parent}: {left.name}')
|
|
25
|
+
elif icmp.diff_info == 'new':
|
|
26
|
+
print(f'Only in {right.parent}: {right.name}')
|
|
27
|
+
elif isinstance(icmp.diff_info, str):
|
|
28
|
+
print(f'Images {left} and {right} differ ({icmp.diff_info})')
|
|
29
|
+
elif icmp.diff_info > rmse_threshold:
|
|
30
|
+
print(f'Images {left} and {right} differ NRMSE: {icmp.diff_info}')
|
|
31
|
+
else:
|
|
32
|
+
diff_output = difflib.unified_diff(
|
|
33
|
+
left.read_text().splitlines(),
|
|
34
|
+
right.read_text().splitlines(),
|
|
35
|
+
fromfile=str(left),
|
|
36
|
+
tofile=str(right),
|
|
37
|
+
lineterm='')
|
|
38
|
+
if diff_output:
|
|
39
|
+
ndiffs += 1
|
|
40
|
+
print('\n'.join(diff_output))
|
|
41
|
+
|
|
42
|
+
return ndiffs
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import pathlib
|
|
4
|
+
|
|
5
|
+
from . import dir_diff, image_diff
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_args():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog='imdiff',
|
|
11
|
+
description="""Compare images one by one or directory by directory""",
|
|
12
|
+
)
|
|
13
|
+
parser.add_argument(
|
|
14
|
+
'--summary',
|
|
15
|
+
action='store_true',
|
|
16
|
+
help="""Print diff result and exit. Disables the GUI. Exits with non-zero if
|
|
17
|
+
there are differences.""",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
'left',
|
|
21
|
+
default='.',
|
|
22
|
+
type=pathlib.Path,
|
|
23
|
+
help="""Image or directory of images to compare.""",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
'right',
|
|
27
|
+
default='.',
|
|
28
|
+
type=pathlib.Path,
|
|
29
|
+
help="""Image or directory of images to compare.""",
|
|
30
|
+
)
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
return args
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main():
|
|
36
|
+
#logging.basicConfig(level=logging.DEBUG)
|
|
37
|
+
args = parse_args()
|
|
38
|
+
if args.left.is_dir():
|
|
39
|
+
assert args.right.is_dir(), \
|
|
40
|
+
'Paths must be either both image files or both directories'
|
|
41
|
+
if args.summary:
|
|
42
|
+
ndiffs = dir_diff.print_diff(args.left, args.right)
|
|
43
|
+
return int(ndiffs > 0)
|
|
44
|
+
else:
|
|
45
|
+
dir_diff.app(args.left, args.right)
|
|
46
|
+
elif args.left.is_file():
|
|
47
|
+
assert args.right.is_file(), \
|
|
48
|
+
'Paths must be either both image files or both directories'
|
|
49
|
+
if args.summary:
|
|
50
|
+
ndiffs = image_diff.print_diff(args.left, args.right)
|
|
51
|
+
return int(ndiffs > 0)
|
|
52
|
+
else:
|
|
53
|
+
image_diff.app(args.left, args.right)
|
|
54
|
+
else:
|
|
55
|
+
raise OSError(f'{args.left} is not a file or directory')
|
|
@@ -4,7 +4,7 @@ import platform
|
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
6
|
import tkinter as tk
|
|
7
|
-
|
|
7
|
+
import ttkbootstrap as ttk
|
|
8
8
|
|
|
9
9
|
from .file_list_frame import FileListFrame
|
|
10
10
|
from .image_frame import ImageFrame
|
|
@@ -14,14 +14,16 @@ from .status_bar import StatusBar
|
|
|
14
14
|
log = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
class DirDiffMainWindow(
|
|
17
|
+
class DirDiffMainWindow(ttk.Window):
|
|
18
18
|
def __init__(self, left_label='left', right_label='right'):
|
|
19
|
-
super().__init__()
|
|
20
|
-
self.title('Image Comparator')
|
|
21
|
-
self.define_custom_style()
|
|
19
|
+
super().__init__(title='Image Comparator', themename='sandstone')
|
|
22
20
|
self.pane_window = tk.PanedWindow(self, orient=tk.HORIZONTAL, sashwidth=8)
|
|
23
|
-
self.file_list_frame = FileListFrame(
|
|
24
|
-
|
|
21
|
+
self.file_list_frame = FileListFrame(
|
|
22
|
+
self.pane_window,
|
|
23
|
+
on_select=self.on_select,
|
|
24
|
+
left_label=left_label,
|
|
25
|
+
right_label=right_label,
|
|
26
|
+
)
|
|
25
27
|
self.image_frame = ImageFrame(self.pane_window, update_item=self.update_item)
|
|
26
28
|
self.image_frame.add_operate_buttons(
|
|
27
29
|
ttk.Button(self.image_frame, text='Delete', command=self.delete),
|
|
@@ -32,20 +34,6 @@ class DirDiffMainWindow(tk.Tk):
|
|
|
32
34
|
self.layout()
|
|
33
35
|
self.bind_events()
|
|
34
36
|
|
|
35
|
-
def define_custom_style(self):
|
|
36
|
-
self.option_add('*tearOff', False)
|
|
37
|
-
self.style = ttk.Style(self)
|
|
38
|
-
if platform.system() == 'Linux':
|
|
39
|
-
self.style.theme_use('clam')
|
|
40
|
-
|
|
41
|
-
if platform.system() == 'Linux':
|
|
42
|
-
height = font.Font().metrics('linespace')
|
|
43
|
-
self.style.configure("Treeview", rowheight=height)
|
|
44
|
-
|
|
45
|
-
self.style.configure('Raised.TButton', relief='raised')
|
|
46
|
-
self.style.configure('Sunken.TButton', relief='sunken')
|
|
47
|
-
self.style.configure('White.TFrame', background='white')
|
|
48
|
-
|
|
49
37
|
def layout(self):
|
|
50
38
|
self.pane_window.add(self.file_list_frame)
|
|
51
39
|
self.pane_window.add(self.image_frame)
|
|
@@ -54,12 +42,12 @@ class DirDiffMainWindow(tk.Tk):
|
|
|
54
42
|
self.columnconfigure(0, weight=1)
|
|
55
43
|
self.rowconfigure(0, weight=1)
|
|
56
44
|
|
|
57
|
-
self.wait_visibility()
|
|
58
|
-
|
|
59
45
|
file_list_frame_minsize = self.file_list_frame.minsize()
|
|
60
46
|
image_frame_minsize = self.image_frame.minsize()
|
|
61
47
|
|
|
62
|
-
self.pane_window.paneconfigure(
|
|
48
|
+
self.pane_window.paneconfigure(
|
|
49
|
+
self.file_list_frame, minsize=file_list_frame_minsize.width
|
|
50
|
+
)
|
|
63
51
|
|
|
64
52
|
w = file_list_frame_minsize.width + image_frame_minsize.width
|
|
65
53
|
h = max(file_list_frame_minsize.height, image_frame_minsize.height)
|
|
@@ -68,10 +56,17 @@ class DirDiffMainWindow(tk.Tk):
|
|
|
68
56
|
def bind_events(self):
|
|
69
57
|
self.bind_all('<Control-q>', self.destroy)
|
|
70
58
|
self.bind_all('<Control-c>', self.destroy)
|
|
71
|
-
self.bind_all(
|
|
72
|
-
self.bind_all(
|
|
73
|
-
|
|
74
|
-
|
|
59
|
+
self.bind_all('<Down>', self.file_list_frame.on_down)
|
|
60
|
+
self.bind_all(
|
|
61
|
+
'<Shift-Down>', lambda evt: self.file_list_frame.on_down(evt, add=True)
|
|
62
|
+
)
|
|
63
|
+
self.bind_all('<Up>', self.file_list_frame.on_up)
|
|
64
|
+
self.bind_all(
|
|
65
|
+
'<Shift-Up>', lambda evt: self.file_list_frame.on_up(evt, add=True)
|
|
66
|
+
)
|
|
67
|
+
self.bind_all('<Delete>', self.delete)
|
|
68
|
+
self.bind_all('<Left>', self.copy_right_to_left)
|
|
69
|
+
self.bind_all('<Right>', self.copy_left_to_right)
|
|
75
70
|
|
|
76
71
|
def destroy(self, *evt):
|
|
77
72
|
self.file_list_frame.signal_shutdown()
|
|
@@ -96,11 +91,11 @@ class DirDiffMainWindow(tk.Tk):
|
|
|
96
91
|
if leftdir and rightdir:
|
|
97
92
|
self.file_list_frame.after_idle(self.file_list_frame.load_files)
|
|
98
93
|
|
|
99
|
-
def delete(self):
|
|
94
|
+
def delete(self, evt=None):
|
|
100
95
|
for file_path in self.file_list_frame.file_treeview.selection():
|
|
101
96
|
self.file_list_frame.delete(file_path)
|
|
102
97
|
|
|
103
|
-
def copy_left_to_right(self):
|
|
98
|
+
def copy_left_to_right(self, evt=None):
|
|
104
99
|
for file_path in self.file_list_frame.file_treeview.selection():
|
|
105
100
|
if comparator := self.file_list_frame.files.get(file_path, None):
|
|
106
101
|
if comparator is self.image_frame.comparator:
|
|
@@ -111,7 +106,7 @@ class DirDiffMainWindow(tk.Tk):
|
|
|
111
106
|
comparator.copy_left_to_right()
|
|
112
107
|
self.update_item(file_path, comparator)
|
|
113
108
|
|
|
114
|
-
def copy_right_to_left(self):
|
|
109
|
+
def copy_right_to_left(self, evt=None):
|
|
115
110
|
for file_path in self.file_list_frame.file_treeview.selection():
|
|
116
111
|
if comparator := self.file_list_frame.files.get(file_path, None):
|
|
117
112
|
if comparator is self.image_frame.comparator:
|
|
@@ -6,10 +6,12 @@ import pathlib
|
|
|
6
6
|
import threading
|
|
7
7
|
import time
|
|
8
8
|
import queue
|
|
9
|
+
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
|
|
11
12
|
import tkinter as tk
|
|
12
|
-
from tkinter import filedialog
|
|
13
|
+
from tkinter import filedialog
|
|
14
|
+
import ttkbootstrap as ttk
|
|
13
15
|
|
|
14
16
|
from ..list_files import list_image_files
|
|
15
17
|
from ..util import Size, separate_thread
|
|
@@ -64,15 +66,26 @@ class FileListFrame(ttk.Frame):
|
|
|
64
66
|
self.rightdir = None
|
|
65
67
|
self.progress_var = tk.IntVar()
|
|
66
68
|
|
|
67
|
-
self.button_choose_left_dir = ttk.Button(
|
|
68
|
-
|
|
69
|
+
self.button_choose_left_dir = ttk.Button(
|
|
70
|
+
self, text=f'{self.left_label.capitalize()}...', command=self.askleftdir
|
|
71
|
+
)
|
|
72
|
+
self.button_choose_right_dir = ttk.Button(
|
|
73
|
+
self, text=f'{self.right_label.capitalize()}...', command=self.askrightdir
|
|
74
|
+
)
|
|
75
|
+
self.button_text_diff = ttk.Button(
|
|
76
|
+
self, text=f'Text Diff', command=self.textdiff
|
|
77
|
+
)
|
|
69
78
|
self.treeview_frame = tk.Frame(self)
|
|
70
|
-
self.progress_bar = ttk.Progressbar(
|
|
79
|
+
self.progress_bar = ttk.Progressbar(
|
|
80
|
+
self.treeview_frame, orient=tk.HORIZONTAL, variable=self.progress_var
|
|
81
|
+
)
|
|
71
82
|
self.file_treeview = ttk.Treeview(self.treeview_frame, columns=('stat',))
|
|
72
83
|
self.file_treeview.heading('#0', text='File')
|
|
73
84
|
self.file_treeview.heading('stat', text='Stat')
|
|
74
|
-
w = font.Font().measure(' Stat (▲) ')
|
|
75
|
-
self.file_treeview.column(
|
|
85
|
+
w = tk.font.Font().measure(' Stat (▲) ')
|
|
86
|
+
self.file_treeview.column(
|
|
87
|
+
'stat', width=w, minwidth=w, stretch=False, anchor='center'
|
|
88
|
+
)
|
|
76
89
|
|
|
77
90
|
self.file_treeview.tag_configure('identical', foreground='black')
|
|
78
91
|
self.file_treeview.tag_configure('missing', foreground='orange')
|
|
@@ -83,8 +96,12 @@ class FileListFrame(ttk.Frame):
|
|
|
83
96
|
self.file_treeview.tag_configure('different', foreground='red')
|
|
84
97
|
self.file_treeview.tag_configure('similar', foreground='black')
|
|
85
98
|
|
|
86
|
-
self.xscroll = ttk.Scrollbar(
|
|
87
|
-
|
|
99
|
+
self.xscroll = ttk.Scrollbar(
|
|
100
|
+
self.treeview_frame, orient=tk.HORIZONTAL, command=self.file_treeview.xview
|
|
101
|
+
)
|
|
102
|
+
self.yscroll = ttk.Scrollbar(
|
|
103
|
+
self.treeview_frame, orient=tk.VERTICAL, command=self.file_treeview.yview
|
|
104
|
+
)
|
|
88
105
|
|
|
89
106
|
self.file_treeview['xscrollcommand'] = self.xscroll.set
|
|
90
107
|
self.file_treeview['yscrollcommand'] = self.yscroll.set
|
|
@@ -99,8 +116,9 @@ class FileListFrame(ttk.Frame):
|
|
|
99
116
|
self.treeview_frame.columnconfigure(0, weight=1)
|
|
100
117
|
self.treeview_frame.rowconfigure(0, weight=1)
|
|
101
118
|
|
|
102
|
-
self.button_choose_left_dir.grid(column=0, row=0, sticky='nw')
|
|
103
|
-
self.button_choose_right_dir.grid(column=1, row=0, sticky='nw')
|
|
119
|
+
self.button_choose_left_dir.grid(column=0, row=0, padx=5, pady=5, sticky='nw')
|
|
120
|
+
self.button_choose_right_dir.grid(column=1, row=0, padx=5, pady=5, sticky='nw')
|
|
121
|
+
self.button_text_diff.grid(column=2, row=0, padx=5, pady=5, sticky='ne')
|
|
104
122
|
self.treeview_frame.grid(column=0, row=1, columnspan=3, sticky='nsew')
|
|
105
123
|
|
|
106
124
|
self.columnconfigure(2, weight=1)
|
|
@@ -108,10 +126,9 @@ class FileListFrame(ttk.Frame):
|
|
|
108
126
|
|
|
109
127
|
def bind_events(self):
|
|
110
128
|
self.file_treeview.bind('<<TreeviewSelect>>', self.on_select)
|
|
111
|
-
self.file_treeview.
|
|
112
|
-
self.file_treeview.
|
|
113
|
-
self.file_treeview.
|
|
114
|
-
self.file_treeview.bind("<Shift-Up>", lambda evt: self.on_up(evt, add=True))
|
|
129
|
+
self.file_treeview.unbind_class('Treeview', '<Down>')
|
|
130
|
+
self.file_treeview.unbind_class('Treeview', '<Up>')
|
|
131
|
+
self.file_treeview.unbind_class('Treeview', '<Right>')
|
|
115
132
|
|
|
116
133
|
def on_down(self, evt, add=False):
|
|
117
134
|
if sel := self.file_treeview.selection():
|
|
@@ -153,7 +170,7 @@ class FileListFrame(ttk.Frame):
|
|
|
153
170
|
self.yscroll,
|
|
154
171
|
)
|
|
155
172
|
w = sum([w.winfo_width() for w in widgets])
|
|
156
|
-
return Size((w, 400))
|
|
173
|
+
return Size((w + 600, 400))
|
|
157
174
|
|
|
158
175
|
def askleftdir(self):
|
|
159
176
|
if topdir := filedialog.askdirectory(initialdir=self.leftdir):
|
|
@@ -165,6 +182,9 @@ class FileListFrame(ttk.Frame):
|
|
|
165
182
|
self.rightdir = pathlib.Path(topdir)
|
|
166
183
|
self.load_files()
|
|
167
184
|
|
|
185
|
+
def textdiff(self):
|
|
186
|
+
subprocess.Popen(('meld', self.leftdir, self.rightdir))
|
|
187
|
+
|
|
168
188
|
def signal_shutdown(self):
|
|
169
189
|
self.file_queue.status = IterableQueue.Status.Inactive
|
|
170
190
|
self.file_processing_queue.status = IterableQueue.Status.Inactive
|
|
@@ -191,7 +211,7 @@ class FileListFrame(ttk.Frame):
|
|
|
191
211
|
self.file_entries.append(file_path)
|
|
192
212
|
self.file_treeview.insert('', 'end', file_path, text=file_path)
|
|
193
213
|
col_width = max(200, self.file_treeview.column('#0', 'width'))
|
|
194
|
-
item_width = int(0.85 * font.Font().measure(file_path))
|
|
214
|
+
item_width = int(0.85 * tk.font.Font().measure(file_path))
|
|
195
215
|
if item_width > col_width:
|
|
196
216
|
self.file_treeview.column('#0', width=item_width)
|
|
197
217
|
|
|
@@ -235,7 +255,9 @@ class FileListFrame(ttk.Frame):
|
|
|
235
255
|
@separate_thread
|
|
236
256
|
def process_files(self):
|
|
237
257
|
assert self.file_properties_queue.status == IterableQueue.Status.Filling
|
|
238
|
-
with concurrent.futures.ThreadPoolExecutor(
|
|
258
|
+
with concurrent.futures.ThreadPoolExecutor(
|
|
259
|
+
max_workers=multiprocessing.cpu_count()
|
|
260
|
+
) as executor:
|
|
239
261
|
self.executor = executor
|
|
240
262
|
try:
|
|
241
263
|
jobs = []
|
|
@@ -259,11 +281,14 @@ class FileListFrame(ttk.Frame):
|
|
|
259
281
|
self.progress_bar.grid_forget()
|
|
260
282
|
|
|
261
283
|
def set_file_properties(self, file_path, category, diff_info):
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
self.
|
|
266
|
-
|
|
284
|
+
try:
|
|
285
|
+
self.file_treeview.set(file_path, 'stat', category)
|
|
286
|
+
self.file_treeview.item(file_path, tags=(diff_info,))
|
|
287
|
+
if self.progress_bar.grid_info():
|
|
288
|
+
self.num_files_loaded += 1
|
|
289
|
+
self.update_progress_bar(100 * self.num_files_loaded // len(self.files))
|
|
290
|
+
except tk.TclError as e:
|
|
291
|
+
log.debug(f'exception caught while setting file properties: {e}')
|
|
267
292
|
|
|
268
293
|
@separate_thread
|
|
269
294
|
def update_properties(self):
|
|
@@ -284,26 +309,31 @@ class FileListFrame(ttk.Frame):
|
|
|
284
309
|
self.process_files()
|
|
285
310
|
self.update_properties()
|
|
286
311
|
|
|
312
|
+
def select_next(self):
|
|
313
|
+
select_next = None
|
|
314
|
+
if sel := self.file_treeview.selection():
|
|
315
|
+
curitem = sel[-1]
|
|
316
|
+
select_next = (
|
|
317
|
+
self.file_treeview.next(curitem)
|
|
318
|
+
or self.file_treeview.prev(curitem)
|
|
319
|
+
)
|
|
320
|
+
if select_next:
|
|
321
|
+
self.file_treeview.selection_add(select_next)
|
|
322
|
+
self.file_treeview.focus(select_next)
|
|
323
|
+
self.file_treeview.see(select_next)
|
|
324
|
+
else:
|
|
325
|
+
self.file_treeview.selection_set()
|
|
326
|
+
self.on_select()
|
|
327
|
+
|
|
287
328
|
def delete_entry(self, file_path):
|
|
288
329
|
del self.files[file_path]
|
|
289
330
|
self.file_entries.remove(file_path)
|
|
290
331
|
try:
|
|
291
|
-
select_next = None
|
|
292
332
|
if sel := self.file_treeview.selection():
|
|
293
333
|
curitem = sel[-1]
|
|
294
334
|
if curitem == file_path:
|
|
295
|
-
select_next
|
|
296
|
-
self.file_treeview.next(curitem)
|
|
297
|
-
or self.file_treeview.prev(curitem)
|
|
298
|
-
)
|
|
335
|
+
self.select_next()
|
|
299
336
|
self.file_treeview.delete(file_path)
|
|
300
|
-
if select_next:
|
|
301
|
-
self.file_treeview.selection_add(select_next)
|
|
302
|
-
self.file_treeview.focus(select_next)
|
|
303
|
-
self.file_treeview.see(select_next)
|
|
304
|
-
else:
|
|
305
|
-
self.file_treeview.selection_set()
|
|
306
|
-
self.on_select()
|
|
307
337
|
except tk.TclError:
|
|
308
338
|
log.debug(f'ignoring attempt to delete nonexistant entry: {file_path}')
|
|
309
339
|
|
|
@@ -320,11 +350,14 @@ class FileListFrame(ttk.Frame):
|
|
|
320
350
|
try:
|
|
321
351
|
file_path = str(pathlib.Path(file_path).relative_to(self.rightdir))
|
|
322
352
|
except ValueError:
|
|
323
|
-
|
|
353
|
+
try:
|
|
354
|
+
file_path = str(pathlib.Path(file_path).relative_to(self.leftdir))
|
|
355
|
+
except ValueError:
|
|
356
|
+
assert not file_path.is_absolute()
|
|
324
357
|
|
|
325
358
|
if (
|
|
326
|
-
|
|
327
|
-
and not (self.rightdir/file_path).exists()
|
|
359
|
+
not (self.leftdir / file_path).exists()
|
|
360
|
+
and not (self.rightdir / file_path).exists()
|
|
328
361
|
):
|
|
329
362
|
self.delete(file_path)
|
|
330
363
|
return
|
|
@@ -336,32 +369,47 @@ class FileListFrame(ttk.Frame):
|
|
|
336
369
|
tv = self.file_treeview
|
|
337
370
|
l = [(pathlib.Path(tv.item(k)['text']), k) for k in tv.get_children()]
|
|
338
371
|
maxparts = max(len(p.parts) for p, _ in l)
|
|
372
|
+
|
|
339
373
|
def transform_key(key, nparts=nparts):
|
|
340
374
|
p = key[0].parts
|
|
341
375
|
if -len(p) <= nparts < len(p):
|
|
342
376
|
return (str(pathlib.Path(*p[nparts:])), str(pathlib.Path(*p[:nparts])))
|
|
343
377
|
else:
|
|
344
378
|
return (str(p),)
|
|
379
|
+
|
|
345
380
|
l.sort(key=transform_key, reverse=reverse)
|
|
346
381
|
for index, (val, k) in enumerate(l):
|
|
347
382
|
tv.move(k, '', index)
|
|
383
|
+
tv.column('#0', anchor=(tk.E if nparts < 0 else tk.W))
|
|
348
384
|
if reverse:
|
|
349
385
|
next_args = (-maxparts if nparts == (maxparts - 1) else (nparts + 1), False)
|
|
350
386
|
else:
|
|
351
387
|
next_args = (nparts, True)
|
|
352
|
-
tv.heading(
|
|
353
|
-
|
|
388
|
+
tv.heading(
|
|
389
|
+
'#0',
|
|
390
|
+
text=f'File ({"▼" if reverse else "▲"}{nparts or ""})',
|
|
391
|
+
command=lambda: self.sort_filepaths(*next_args),
|
|
392
|
+
)
|
|
354
393
|
tv.heading('stat', text='Stat')
|
|
355
394
|
|
|
356
395
|
def sort_stat(self, reverse):
|
|
357
396
|
tv = self.file_treeview
|
|
358
|
-
colindex = tv[
|
|
359
|
-
|
|
397
|
+
colindex = tv['columns'].index('stat')
|
|
398
|
+
try:
|
|
399
|
+
l = [(tv.item(k)['values'][colindex], k) for k in tv.get_children()]
|
|
400
|
+
except IndexError:
|
|
401
|
+
# do not sort if we are in the proccess of updating the diffs
|
|
402
|
+
return
|
|
403
|
+
|
|
360
404
|
def transform_key(key):
|
|
361
405
|
return ('F', 'L', 'D', 'S', '+', '~', '-', '=').index(key[0])
|
|
406
|
+
|
|
362
407
|
l.sort(key=transform_key, reverse=reverse)
|
|
363
408
|
for index, (val, k) in enumerate(l):
|
|
364
409
|
tv.move(k, '', index)
|
|
365
410
|
tv.heading('#0', text='File')
|
|
366
|
-
tv.heading(
|
|
367
|
-
|
|
411
|
+
tv.heading(
|
|
412
|
+
'stat',
|
|
413
|
+
text=f'Stat ({"▼" if reverse else "▲"})',
|
|
414
|
+
command=lambda: self.sort_stat(not reverse),
|
|
415
|
+
)
|
|
@@ -73,11 +73,17 @@ class ImageCanvas(tk.Canvas):
|
|
|
73
73
|
else:
|
|
74
74
|
image_size = Size((self.image.width, self.image.height))
|
|
75
75
|
canvas_size = Size((self.winfo_width(), self.winfo_height()))
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
76
|
+
border_size = Size((2 * self.border, 2 * self.border))
|
|
77
|
+
scaled_size = self.scaling.scaled(
|
|
78
|
+
image_size, canvas_size - border_size
|
|
79
|
+
)
|
|
80
|
+
if scaled_size.width > 0 and scaled_size.height > 0:
|
|
81
|
+
if self.scaled_image is None or scaled_size != Size(
|
|
82
|
+
(self.scaled_image.width, self.scaled_image.height)
|
|
83
|
+
):
|
|
84
|
+
self.scaling_percent = int(
|
|
85
|
+
100 * scaled_size.width / image_size.width
|
|
86
|
+
)
|
|
81
87
|
self.scaled_image = self.image.resize(scaled_size)
|
|
82
88
|
log.debug(f'[{self.label}] scale to {self.scaling_percent}%')
|
|
83
89
|
self.scaled_image_updated = True
|
|
@@ -88,7 +94,9 @@ class ImageCanvas(tk.Canvas):
|
|
|
88
94
|
log.debug(f'[{self.label}] removed scaled image')
|
|
89
95
|
|
|
90
96
|
def update_displayed_image(self):
|
|
91
|
-
log.debug(
|
|
97
|
+
log.debug(
|
|
98
|
+
f'[{self.label}] update displayed image (scaled image: {self.scaled_image}'
|
|
99
|
+
)
|
|
92
100
|
if self.image:
|
|
93
101
|
if self.scaled_image and self.scaled_image_updated:
|
|
94
102
|
self.tkimage = ImageTk.PhotoImage(self.scaled_image)
|
|
@@ -96,14 +104,18 @@ class ImageCanvas(tk.Canvas):
|
|
|
96
104
|
self.itemconfig(self.image_id, image=self.tkimage)
|
|
97
105
|
log.debug(f'[{self.label}] updated displayed image')
|
|
98
106
|
else:
|
|
99
|
-
self.image_id = self.create_image(
|
|
107
|
+
self.image_id = self.create_image(
|
|
108
|
+
self.border, self.border, image=self.tkimage, anchor='nw'
|
|
109
|
+
)
|
|
100
110
|
log.debug(f'[{self.label}] displaying new image')
|
|
101
111
|
else:
|
|
102
112
|
self.clear()
|
|
103
113
|
log.debug(f'[{self.label}] removed scaled image')
|
|
104
114
|
|
|
105
115
|
def update_layout(self):
|
|
106
|
-
log.debug(
|
|
116
|
+
log.debug(
|
|
117
|
+
f'[{self.label}] update layout (image id: {self.image_id} scaled image: {self.scaled_image})'
|
|
118
|
+
)
|
|
107
119
|
if self.image_id and self.scaled_image:
|
|
108
120
|
w = self.winfo_width()
|
|
109
121
|
h = self.winfo_height()
|
|
@@ -112,8 +124,16 @@ class ImageCanvas(tk.Canvas):
|
|
|
112
124
|
self.is_draggable = (w < iw) or (h < ih)
|
|
113
125
|
if self.position is None:
|
|
114
126
|
self.position = Coordinates(self.coords(self.image_id))
|
|
115
|
-
self.position.x =
|
|
116
|
-
|
|
127
|
+
self.position.x = (
|
|
128
|
+
min(max(self.position.x, w - iw - self.border), self.border)
|
|
129
|
+
if (w < iw)
|
|
130
|
+
else ((w - iw) // 2)
|
|
131
|
+
)
|
|
132
|
+
self.position.y = (
|
|
133
|
+
min(max(self.position.y, h - ih - self.border), self.border)
|
|
134
|
+
if (h < ih)
|
|
135
|
+
else ((h - ih) // 2)
|
|
136
|
+
)
|
|
117
137
|
if self.position != Coordinates(self.coords(self.image_id)):
|
|
118
138
|
log.debug(f'[{self.label}] move to {self.position}')
|
|
119
139
|
self.moveto(self.image_id, self.position.x, self.position.y)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import tkinter as tk
|
|
7
|
+
import ttkbootstrap as ttk
|
|
8
|
+
|
|
9
|
+
from .image_frame import ImageFrame
|
|
10
|
+
from .status_bar import StatusBar
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImageDiffMainWindow(tk.Tk):
|
|
17
|
+
def __init__(self, left_label='left', right_label='right'):
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.title('Image Comparator')
|
|
20
|
+
self.image_frame = ImageFrame(self)
|
|
21
|
+
self.image_frame.add_standard_operate_buttons()
|
|
22
|
+
self.status_bar = StatusBar(self)
|
|
23
|
+
self.layout()
|
|
24
|
+
self.bind_events()
|
|
25
|
+
|
|
26
|
+
def layout(self):
|
|
27
|
+
self.image_frame.grid(column=0, row=0, sticky='nsew')
|
|
28
|
+
self.status_bar.grid(column=0, row=1, sticky='sew')
|
|
29
|
+
self.columnconfigure(0, weight=1)
|
|
30
|
+
self.rowconfigure(0, weight=1)
|
|
31
|
+
|
|
32
|
+
self.wait_visibility()
|
|
33
|
+
|
|
34
|
+
image_frame_minsize = self.image_frame.minsize()
|
|
35
|
+
w = image_frame_minsize.width
|
|
36
|
+
h = image_frame_minsize.height
|
|
37
|
+
self.minsize(w, h)
|
|
38
|
+
|
|
39
|
+
def bind_events(self):
|
|
40
|
+
self.bind_all('<Control-q>', self.destroy)
|
|
41
|
+
self.bind_all('<Control-c>', self.destroy)
|
|
42
|
+
self.bind_all('<Escape>', self.destroy)
|
|
43
|
+
self.bind_all('<Delete>', self.image_frame.delete)
|
|
44
|
+
self.bind_all('<Left>', self.image_frame.copy_left_to_right)
|
|
45
|
+
self.bind_all('<Right>', self.image_frame.copy_right_to_left)
|
|
46
|
+
|
|
47
|
+
def destroy(self, *evt):
|
|
48
|
+
super().destroy()
|
|
49
|
+
sys.exit()
|
|
50
|
+
|
|
51
|
+
def load(self, left=None, right=None):
|
|
52
|
+
self.image_frame.load_images(left, right)
|
|
53
|
+
if comp := self.image_frame.comparator:
|
|
54
|
+
self.after_idle(self.status_bar.update_labels, comp)
|