/
view.py
242 lines (197 loc) · 10.8 KB
/
view.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
from PyQt5.QtCore import Qt, QEvent
from PyQt5.QtGui import QPixmap, QTransform, QStatusTipEvent
from PyQt5.QtWidgets import QMainWindow, QFileDialog, QMessageBox
from observer import Subject
from widgets import ExifWidget, ImageWidget, StatusBar, MenuBar, ToolBar, Layout, AboutWidget
class View(Subject, QMainWindow):
"""Main window class, and the View of the MVC pattern.
Attributes:
model: The model of the MVC pattern.
menu_bar: A menu bar derived from QMenuBar.
tool_bar: A tool bar derived from QToolBar.
exif_area: The widget containing any available EXIF data.
image_area: The widget containing the displayed image.
status_bar: A status bar derived from QStatusBar.
about: The classic "About" informative widget (actually a new, separate window).
"""
def __init__(self, model):
"""Inits the class."""
Subject.__init__(self)
QMainWindow.__init__(self)
# Set model
self.model = model
# Create interface elements
self.menu_bar = MenuBar(self)
self.tool_bar = ToolBar(self)
self.exif_area = ExifWidget(self)
self.image_area = ImageWidget(self)
self.status_bar = StatusBar()
about_text = 'IEViewer 2.0' \
'<br><br>' \
'Copyright © 2021 by' \
'<br>' \
'Paula Mihalcea' \
'<br>' \
'<a href="mailto:paula.mihalcea@live.com">paula.mihalcea@live.com</a>' \
'<br><br>' \
'This program uses PyQt5, a comprehensive set of Python bindings for Qt v5. Qt is a set of cross-platform C++ libraries that implement high-level APIs for accessing many aspects of modern desktop and mobile systems.\n' \
'<br><br>' \
'PyQt5 is copyright © Riverbank Computing Limited. Its homepage is <a href="https://www.riverbankcomputing.com/software/pyqt/">https://www.riverbankcomputing.com/software/pyqt/</a>.' \
'<br><br>' \
'Photo manipulation detection algorithm by Shruti Agarwal and Hany Farid (<i><a href="https://doi.org/10.1145/3369412.3395059">Photo Forensics From Rounding Artifacts</i></a>); Python implementation by Paula Mihalcea (<a href="https://github.com/PaulaMihalcea/Photo-Forensics-from-Rounding-Artifacts">source</a>).' \
'<br><br>' \
'No genasi were harmed in the making of this application. <a href="https://www.dndbeyond.com/races/genasi#WaterGenasi">#GenasiLivesMatter#NereisThalian</a>'
self.about = AboutWidget('About IEViewer', about_text, image_path='icons/about_img.png')
# Disable GUI elements that are unavailable when no image is opened
self.menu_bar.disable_widgets()
self.exif_area.hide()
# Set layout
self.setCentralWidget(Layout(self).central_widget)
# Set window properties
self.set_window_properties()
# Install additional event filters
self.image_area.installEventFilter(self)
def set_window_properties(self):
"""Sets some main window properties."""
self.statusBar()
self.setStatusTip('Ready.')
self.setWindowTitle('IEViewer') # Window title (the one in the title bar)
self.resize(512, 256) # These default dimensions should be fine for most displays
def get_open_file_dialog(self, caption, filter):
"""Opens an "Open File" dialog and returns a file path. If no file has been selected and the user has pressed "Cancel" or closed the dialog, then it returns None."""
file_path, _ = QFileDialog.getOpenFileName(caption=caption, directory='', filter=filter)
if file_path == '':
return None
else:
return file_path
def get_save_file_dialog(self, caption, filter):
"""Opens a "Save As" dialog and returns a file path and format. If no name has been entered and the user has pressed "Cancel" or closed the dialog, then it returns None."""
file_path, format = QFileDialog.getSaveFileName(caption=caption, directory='', filter=filter) # By omitting the directory argument (empty string, ''), the dialog should remember the last directory (depends on operating system)
if file_path == '':
return None, None
else:
format = format.split(' ')
return file_path, format[0]
def show_message_box(self, title, text, icon=QMessageBox.Information):
"""Opens a simple message box window, with some text and a customizable icon."""
info_box = QMessageBox(self)
if icon is not None:
info_box.setIcon(icon)
info_box.setWindowTitle(title)
info_box.setText(text)
info_box.show()
def event(self, e):
"""Defines the default status bar message (when nothing else is displayed)."""
if e.type() == QEvent.StatusTip:
if e.tip() == '':
e = QStatusTipEvent('Ready.')
return super().event(e)
def open(self):
"""Only needed to notify observers (i.e. the controller) that they should load an image."""
self.set_state('open')
def load_image(self):
"""Displays the image contained in the model."""
# Get original image dimensions
width = self.model.image.width()
height = self.model.image.height()
# Recalculate image dimensions so as to have a maximum dimension (height or width) of 512 pixels
if width >= height and width > 512:
w = 512
h = int(512 * height / width)
elif height >= width and height > 512:
w = int(512 * width / height)
h = 512
else:
w = width
h = height
# Generate and set a pixmap from the image
self.image_area.set_image(self.model.image, w, h)
# Resize the window and pixmap (generated from the image),
# so as to display correctly the window title, menu bar and image (actually, its pixmap)
#
# The diff parameter is another piece of PyQt5 magic.
# Without it, an image larger than 512 pixels might be displayed smaller
# than the maximum 512 that it has been resized to
# (despite having been resized to 512 pixels).
diff = 130 # This value is good for both Windows 10 and Ubuntu 20.04
if w < 280: # Again, this should suffice for both OSs
self.resize(280, h + diff)
else:
self.resize(w, h + diff)
self.image_area.pixmap.scaled(w, h)
# Add EXIF data from the model
self.exif_area.load_exif()
# Update window title
self.setWindowTitle('IEViewer - ' + self.model.filename)
# Enable menus
self.menu_bar.enable_widgets()
def save(self):
"""Saves the modified image. Uses the stored original image, not the modified version currently displayed in the window."""
self.model.set_image(self.model.image.transformed(QTransform().rotate(self.image_area.rot), Qt.SmoothTransformation))
self.set_state('save')
def saveas(self):
"""Saves the modified image with another name. Uses the stored original image, not the modified version currently displayed in the window."""
self.model.set_image(self.model.image.transformed(QTransform().rotate(self.image_area.rot), Qt.SmoothTransformation))
self.set_state('saveas')
def eventFilter(self, widget, event):
"""Adds new behavior for certain events."""
# Resize event
# Or what should happen to the image widget when the user resizes the main window
if event.type() == QEvent.Resize and widget is self.image_area and self.image_area.pixmap is not None:
self.image_area.setPixmap(QPixmap.fromImage(self.model.modified_image).scaled(self.image_area.width(), self.image_area.height(), aspectRatioMode=Qt.KeepAspectRatio, transformMode=Qt.SmoothTransformation))
# Update new dimensions for later use
self.image_area.w = self.image_area.width()
self.image_area.h = self.image_area.height()
return True
return QMainWindow.eventFilter(self, widget, event)
def close(self):
"""Closes an image."""
self.set_state('close')
# Clear image area widget and adjust layout
self.image_area.clear_image()
self.exif_area = ExifWidget(self)
self.setCentralWidget(Layout(self).central_widget)
# Disable unavailable menus
self.menu_bar.disable_widgets()
# Hide EXIF area (if visible) and show blank image widget
self.exif_area.hide()
self.image_area.show()
# Update window title
self.setWindowTitle('IEViewer')
def exit(self):
"""Only needed to notify observers (i.e. the controller) that they should exit the application."""
self.set_state('exit')
def rotate(self, degree):
"""Base function for rotation.
Subsequent rotation functions are only needed as hooks for menu actions; actual rotations happen here.
Here, the View directly changes the Model. Arguably, this is a violation of the MVC pattern. It happens that, for some reason, returning a reference to the rotated image to the Controller in order to have the Controller itself change the Model does not work, probably because it only returns a reference and not a copy of the rotated image. PyQt5 does not allow deep copies of its objects, so the following seems to be the only way to make it work.
"""
self.model.set_modified_image(self.model.image.transformed(QTransform().rotate(self.image_area.rot+degree), Qt.SmoothTransformation)) # Rotate image
self.image_area.set_image(self.model.modified_image, self.image_area.w, self.image_area.h) # Display rotated image
self.image_area.rot += degree # Update rotation history
def rotate180(self):
"""Rotate the displayed image by 180 degrees."""
self.rotate(180)
def rotate90c(self):
"""Rotate the displayed image by 90 degrees clock wise."""
self.rotate(90)
def rotate90cc(self):
"""Rotate the displayed image by 90 degrees clock wise."""
self.rotate(-90)
def reset_image(self):
"""Reset the displayed image to its original orientation."""
self.rotate(-self.image_area.rot)
def show_exif(self):
"""Display EXIF data (and at the same time hide the image)."""
if self.exif_area.isHidden():
self.image_area.hide()
self.exif_area.show()
else:
self.image_area.show()
self.exif_area.hide()
def analyze(self):
"""Analyze image to find photo manipulations using an EM algorithm (and at the same time hide the image)."""
self.set_state('analyze')
def about(self):
"""Display info about the program."""
self.about.show()