├── .gitignore
├── requirements.txt
├── LICENSE
├── readme.md
└── app.py
/.gitignore:
--------------------------------------------------------------------------------
1 | settings.ini
2 | VPtree.pickle
3 | Hashing.pickle
4 | .venv
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | certifi==2023.7.22
2 | charset-normalizer==3.3.2
3 | chime==0.7.0
4 | idna==3.4
5 | numpy==1.26.1
6 | Pillow==10.2.0
7 | PyQt6==6.6.0
8 | PyQt6-Qt6==6.6.0
9 | PyQt6-sip==13.6.0
10 | requests==2.31.0
11 | urllib3==2.0.7
12 | vptree==1.3
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 OurGuru
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Offline Reverse Image Search
2 |
3 | ## Overview
4 |
5 | This app finds duplicate to near duplicate images by generating a hash value for each image stored with a specialized data structure called VP-Tree which makes searching an image on a dataset of 100Ks almost instantanious
6 |
7 | ## Updates
8 |
9 | 0.0.4:
10 | * Better error handling
11 | * fixed compatibility with different OS
12 | * minor bug fixes
13 |
14 | 0.0.3:
15 | * Added a log of actions tab
16 | * Better support for drag n drop
17 | * Fixed various crashes and better error handling
18 | * Upgraded the quality of code
19 |
20 | 0.0.2:
21 | * Replaced OpenCV with Pillow
22 | * Various Small Fixes to prevent crashing
23 | * Added Progress bar
24 | * Added Splash Logo (with more UI Plans on future updates) Thanks to [Creative Force](https://www.facebook.com/creativethunder.eu)
25 |
26 | 0.0.1:
27 | * Deprecated .ui & success.wav file
28 | * Added "always on top" on settings
29 | * Decreased possible crashes on drag n drop
30 |
31 | ## Online Examples
32 | Online examples of this are [Google images](https://images.google.com/) & [TinEye](https://tineye.com/) which return near duplicate results from images they indexed across the web
33 |
34 | ## App UI & example
35 | On the left you can see the given image *(for the example it's slightly different from the original)* and on the right the result
36 |
37 |
38 |
39 | - You can also drag n drop an image from browser! (Firefox / Chrome tested)
40 |
41 |
42 | ## Dependencies
43 |
44 | - Python 3.7+ (haven't tested with older versions)
45 |
46 | ## Requirements & installation
47 |
48 | required versions of the Python 3 modules can be found on the requirements.txt
49 |
50 | *Pyqt6 is used but works with PyQt5 too if you update the ``app.py`` file modules import*
51 |
52 | ### Windows
53 | pip install -r /path/to/requirements.txt
54 | ### Linux
55 | pip3 install -r /path/to/requirements.txt
56 |
57 | run ``app.py`` to start
58 |
59 | ## First run:
60 | settings.ini will be created on the directory
61 |
62 | Hashing Pickle & VPTree pickle files will be created on the first index
63 |
64 | ## More detail & Credits
65 |
66 | - [What is dHash and how it works](https://github.com/Rayraegah/dhash#difference-value-hash-dhash)
67 | - [A good guide about Python Pickle](https://zetcode.com/python/pickle/)
68 |
69 | Special Thanks to Adrian Rosebrock for his tutorials that made this possible
70 | [Detailed Guide how everything works and step by step make your own](https://www.pyimagesearch.com/2019/08/26/building-an-image-hashing-search-engine-with-vp-trees-and-opencv/#download-the-code)
71 |
72 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from re import findall
3 | import sys
4 | import os
5 | from pickle import loads as pickleloads
6 | from pickle import dump as pickledump
7 | from pickle import dumps as pickledumps
8 | from collections import deque
9 | import base64
10 | from io import BytesIO
11 | from datetime import datetime
12 | import subprocess
13 | from PyQt6 import QtCore, QtGui
14 | from PyQt6.QtWidgets import QFileDialog, QApplication, QMainWindow,QWidget,QGridLayout,QTabWidget, QPushButton,QLabel,QLineEdit,QSpinBox,QCheckBox,QSplashScreen,QTextEdit, QProgressBar
15 | from PyQt6.QtGui import QPixmap, QIcon
16 | from requests import get as requestsGet
17 | from chime import success as SuccessSound
18 | from chime import info as ErrorSound
19 | from PIL import Image
20 | from numpy import array as nparray
21 | from vptree import VPTree
22 |
23 | def dhash(image, hash_size=8):
24 | """
25 | Computes the difference hash (dhash) of the input image.
26 |
27 | Parameters:
28 | image (str): The path to the input image file.
29 | hash_size (int): The desired hash size (default is 8).
30 |
31 | Returns:
32 | int: The dhash value of the input image.
33 | """
34 | # Check if the input image file exists and can be opened. If not, raise an error.
35 | try:
36 | gray = Image.open(image)
37 | except Exception as e:
38 | raise ValueError(f"Failed to open image file: {str(e)}")
39 | # Load the image file and convert it to grayscale. This simplifies the
40 | # subsequent calculations and reduces the amount of data we need to process.
41 | gray = gray.convert('L')
42 |
43 | # Resize the grayscale image, adding a single column (width) so we can compute
44 | # the horizontal gradient. The horizontal gradient is the difference in
45 | # intensity between adjacent pixels in the horizontal direction.
46 | resized = gray.resize((hash_size + 1, hash_size))
47 |
48 | # Convert the resized image to a numpy array. This allows us to perform
49 | # efficient element-wise operations on the image data.
50 | resized = nparray(resized)
51 |
52 | # Compute the (relative) horizontal gradient between adjacent column pixels.
53 | # This gives us a binary matrix where True indicates that the pixel to the
54 | # right is brighter than the current pixel, and False indicates the opposite.
55 | diff = resized[:, 1:] > resized[:, :-1]
56 |
57 | # Convert the difference image to a hash. We do this by flattening the binary
58 | # matrix into a 1D array, enumerating over it, and for each True value (i.e.,
59 | # each '1' bit in the hash), calculating 2 raised to the power of its index.
60 | # The resulting hash is the sum of these values.
61 | return sum([2 ** i for (i, v) in enumerate(diff.flatten()) if v])
62 |
63 | def convert_hash(h):
64 | """
65 | Converts a hash to NumPy's 64-bit float and then back to Python's built-in int.
66 |
67 | Args:
68 | h (int): The hash to be converted.
69 |
70 | Returns:
71 | int: The converted hash as a Python built-in int.
72 | """
73 | try:
74 | return int(nparray(h, dtype="float64"))
75 | except ValueError:
76 | raise ValueError("Invalid input for hash conversion")
77 |
78 | class Worker(QtCore.QObject):
79 | """
80 | A class that defines a worker object for running a reverse image search engine.
81 |
82 | Attributes:
83 | -----------
84 | progress : QtCore.pyqtSignal
85 | A signal that emits an integer value indicating the progress of the search.
86 | finished : QtCore.pyqtSignal
87 | A signal that emits when the search is finished.
88 | """
89 | def __init__(self, images_found, file_path, photo_main, photo_viewer, next_button, previous_button, open_image_button, label_result, label_current, set_image, append_colored_text, display_queue, current,settings):
90 | super().__init__()
91 | self.images_found = images_found
92 | self.file_path = file_path
93 | self.photo_main = photo_main
94 | self.photo_viewer = photo_viewer
95 | self.next_button = next_button
96 | self.previous_button = previous_button
97 | self.open_image_button = open_image_button
98 | self.label_result = label_result
99 | self.label_current = label_current
100 | self.set_image = set_image
101 | self.append_colored_text = append_colored_text
102 | self.display_queue = display_queue
103 | self.current = current
104 | self.settings = settings
105 |
106 | progress = QtCore.pyqtSignal(int)
107 | finished = QtCore.pyqtSignal()
108 | current_updated = QtCore.pyqtSignal(str)
109 |
110 | def run(self):
111 | """
112 | Runs the reverse image search engine and emits the finished signal when complete.
113 | """
114 | # window.theEngine()
115 |
116 | # self.finished.emit()
117 | self.images_found.clear()
118 |
119 | #check if the VP-Tree and Hashing files exist
120 | if not self.settings.value('VPTree') or not self.settings.value('Hashing'):
121 | error_message = "[ERROR] index could not be loaded. VP-Tree or Hashing file not found"
122 | self.append_colored_text(error_message, QtGui.QColor("red"))
123 | ErrorSound()
124 | self.photo_main.setText('Error')
125 | return
126 |
127 | vptree_path = self.settings.value('VPTree')
128 | hashing_path = self.settings.value('Hashing')
129 |
130 | #check if the VP-Tree and Hashing files exist
131 | if not os.path.exists(vptree_path) or not os.path.exists(hashing_path):
132 | error_message = "[ERROR] index could not be loaded. VP-Tree or Hashing file not found"
133 | self.append_colored_text(error_message, QtGui.QColor("red"))
134 | ErrorSound()
135 | self.photo_main.setText('Error')
136 | return
137 |
138 | #check search_range if it is a valid integer
139 | if not self.settings.value('search_range') or not self.settings.value('search_range').isnumeric():
140 | error_message = "[ERROR] search_range is not a valid integer"
141 | self.append_colored_text(error_message, QtGui.QColor("red"))
142 | ErrorSound()
143 | self.photo_main.setText('Error')
144 | return
145 |
146 | try:
147 | with open(vptree_path, 'rb') as vptree_file:
148 | vptree = pickleloads(vptree_file.read())
149 | with open(hashing_path, 'rb') as hashing_file:
150 | hashes = pickleloads(hashing_file.read())
151 | except Exception as e:
152 | error_message = f"[ERROR] Failed to load index: {str(e)}"
153 | self.append_colored_text(error_message, QtGui.QColor("red"))
154 | ErrorSound()
155 | self.photo_main.setText('Error')
156 | return
157 | query_hash = dhash(self.file_path)
158 | query_hash = convert_hash(query_hash)
159 | try:
160 | results = vptree.get_all_in_range(query_hash, int(self.settings.value('search_range')))
161 | except Exception as e:
162 | error_message = f"[ERROR] Failed to search index: {str(e)}"
163 | self.append_colored_text(error_message, QtGui.QColor("red"))
164 | ErrorSound()
165 | self.photo_main.setText('Error')
166 | return
167 |
168 | results = sorted(results)
169 |
170 | for (dist, hsh) in results:
171 | result_paths = hashes.get(hsh, [])
172 | for result_path in result_paths:
173 | self.images_found.append(result_path)
174 | try:
175 | # self.photo_main.setPixmap(QPixmap(self.file_path).scaled(self.photo_main.width(), self.photo_main.height(), QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation))
176 | if os.path.isfile(self.file_path):
177 | self.photo_main.setPixmap(QPixmap(self.file_path).scaled(self.photo_main.width(), self.photo_main.height(), QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.TransformationMode.SmoothTransformation))
178 | else:
179 | self.photo_main.setText('Image file not found')
180 | self.photo_viewer.setText('Image file not found')
181 | self.next_button.setEnabled(False)
182 | self.previous_button.setEnabled(False)
183 | self.open_image_button.setEnabled(False)
184 | ErrorSound()
185 | return
186 | except Exception as e:
187 | error_message = f"[ERROR] Failed to set image: {str(e)}"
188 | self.photo_viewer.setText('Nothing Found')
189 | self.next_button.setEnabled(False)
190 | self.previous_button.setEnabled(False)
191 | self.open_image_button.setEnabled(False)
192 | return
193 | if len(self.images_found) > 1:
194 | self.next_button.setEnabled(True)
195 | self.open_image_button.setEnabled(True)
196 | self.display_queue.clear()
197 | self.display_queue.extend(self.images_found)
198 | if self.display_queue:
199 | self.current = self.display_queue[0]
200 | self.label_current.setText(f'Current {self.images_found.index(self.current) + 1}/{len(self.images_found)}')
201 | self.set_image(self.current)
202 | self.label_result.setText(f'Total results : {len(self.images_found)}')
203 | logging.info('Links of found images:')
204 | for image_link in self.images_found:
205 | logging.info(image_link)
206 | logging.info('done0')
207 | self.finished.emit()
208 |
209 |
210 | class IndexWorker(QtCore.QObject):
211 | """
212 | A worker class that indexes images in the background.
213 |
214 | Attributes:
215 | - progress (QtCore.pyqtSignal): A signal that emits the progress of the indexing.
216 | - finished (QtCore.pyqtSignal): A signal that emits when the indexing is finished.
217 | """
218 |
219 | progress = QtCore.pyqtSignal(int)
220 | finished = QtCore.pyqtSignal()
221 |
222 | def run(self):
223 | """
224 | Runs the indexing process in the background.
225 | """
226 | window.image_indexer()
227 | self.finished.emit()
228 |
229 |
230 | def get_app_logo():
231 | """
232 | Returns the application logo.
233 |
234 | Returns:
235 | QIcon: The application logo
236 | """
237 | BASE64_PHOTO = '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'
238 | app_logo = QPixmap()
239 | app_logo.loadFromData(base64.b64decode(BASE64_PHOTO))
240 | return app_logo
241 |
242 | class SplashScreen(QSplashScreen):
243 | """
244 | Splash Screen Class
245 | """
246 | def __init__(self):
247 | super().__init__()
248 | self.setWindowFlags(QtCore.Qt.WindowType.FramelessWindowHint | QtCore.Qt.WindowType.WindowStaysOnTopHint)
249 | self.setWindowIcon(QIcon(get_app_logo()))
250 | self.setPixmap(get_app_logo())
251 |
252 | class MyWindow(QMainWindow):
253 | """
254 | Main Window Class
255 | """
256 | def __init__(self):
257 | super(MyWindow, self).__init__()
258 | self.test_file_ext = ''
259 | self.current = ''
260 | self.file_path = ''
261 | self.dir_path = os.path.join(os.getcwd(), '')
262 | if not os.path.isfile(self.dir_path+'settings.ini'):
263 | with open(self.dir_path+'settings.ini', 'w', encoding='utf-8') as f:
264 | vp_tree_path = os.path.join(self.dir_path, 'VPtree.pickle').replace('\\', '/')
265 | hashing_path = os.path.join(self.dir_path, 'Hashing.pickle').replace('\\', '/')
266 | f.write(f'[General]\nVPTree={vp_tree_path}\nHashing={hashing_path}\nsearch_range=6')
267 | print('no settings file, created one')
268 | self.settings = QtCore.QSettings(self.dir_path+'settings.ini', QtCore.QSettings.Format.IniFormat)
269 | self.images_found = []
270 | self.display_queue=deque()
271 | #Last UI Configs & connect UI buttons to functions
272 | self.setObjectName("Offline")
273 | self.setWindowTitle("Offline Reverse Image Search")
274 | self.resize(900, 900)
275 | self.setAcceptDrops(True)
276 | self.central_widget = QWidget(self)
277 | self.central_widget.setAcceptDrops(True)
278 | self.central_widget.setObjectName("central_widget")
279 | self.grid_layout_4 = QGridLayout(self.central_widget)
280 | self.grid_layout_4.setObjectName("grid_layout_4")
281 | self.tab_widget = QTabWidget(self.central_widget)
282 | self.tab_widget.setEnabled(True)
283 | self.tab_widget.setAcceptDrops(True)
284 | self.tab_widget.setStyleSheet("")
285 | self.tab_widget.setObjectName("tabWidget")
286 | self.tab1 = QWidget()
287 | self.tab1.setAcceptDrops(True)
288 | self.tab1.setWhatsThis("")
289 | self.tab1.setObjectName("tab1")
290 | self.grid_layout_3 = QGridLayout(self.tab1)
291 | self.grid_layout_3.setContentsMargins(0, 0, 0, 0)
292 | self.grid_layout_3.setObjectName("grid_layout_3")
293 | self.grid_layout = QGridLayout()
294 | self.grid_layout.setContentsMargins(0, -1, -1, 0)
295 | self.grid_layout.setSpacing(6)
296 | self.grid_layout.setObjectName("gridLayout")
297 | self.image_clear_button = QPushButton("Clear Image",self.tab1)
298 | self.image_clear_button.setEnabled(True)
299 | self.image_clear_button.setObjectName("image_clear_button")
300 | self.grid_layout.addWidget(self.image_clear_button, 3, 1, 1, 1)
301 | self.next_button = QPushButton("Next",self.tab1)
302 | self.next_button.setEnabled(False)
303 | self.next_button.setObjectName("next_button")
304 | self.grid_layout.addWidget(self.next_button, 2, 1, 1, 1)
305 | self.label_current = QLabel("Current",self.tab1)
306 | self.label_current.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
307 | self.label_current.setObjectName("label_current")
308 | self.grid_layout.addWidget(self.label_current, 1, 1, 1, 1)
309 | self.previous_button = QPushButton("Previous",self.tab1)
310 | self.previous_button.setEnabled(False)
311 | self.previous_button.setObjectName("previous_button")
312 | self.grid_layout.addWidget(self.previous_button, 2, 0, 1, 1)
313 | self.photo_viewer = QLabel(self.tab1)
314 | self.photo_viewer.setMinimumSize(QtCore.QSize(1, 1))
315 | self.photo_viewer.setAcceptDrops(True)
316 | self.photo_viewer.setStyleSheet("QLabel{border: 4px dashed #aba}")
317 | self.photo_viewer.setText("")
318 | self.photo_viewer.setScaledContents(False)
319 | self.photo_viewer.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
320 | self.photo_viewer.setObjectName("photo_viewer")
321 | self.grid_layout.addWidget(self.photo_viewer, 0, 1, 1, 1)
322 | self.open_image_button = QPushButton("Open Directory",self.tab1)
323 | self.open_image_button.setEnabled(False)
324 | self.open_image_button.setObjectName("open_image_button")
325 | self.grid_layout.addWidget(self.open_image_button, 3, 0, 1, 1)
326 | self.label_result = QLabel("Total Results: ",self.tab1)
327 | self.label_result.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
328 | self.label_result.setObjectName("label_result")
329 | self.grid_layout.addWidget(self.label_result, 1, 0, 1, 1)
330 | self.photo_main = QLabel(self.tab1)
331 | self.photo_main.setMinimumSize(QtCore.QSize(1, 1))
332 | self.photo_main.setAcceptDrops(True)
333 | self.photo_main.setStyleSheet("QLabel{border: 4px dashed #aba\n}")
334 | self.photo_main.setText("")
335 | self.photo_main.setScaledContents(False)
336 | self.photo_main.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
337 | self.photo_main.setObjectName("photo_main")
338 | self.grid_layout.addWidget(self.photo_main, 0, 0, 1, 1)
339 | self.grid_layout.setRowStretch(0, 1)
340 | self.grid_layout_3.addLayout(self.grid_layout, 0, 0, 1, 1)
341 | self.tab_widget.addTab(self.tab1, "Finder")
342 | self.tab2 = QWidget()
343 | self.tab2.setObjectName("tab2")
344 | self.tab3 = QWidget()
345 | self.tab3.setObjectName("tab3")
346 | self.save_settings_button = QPushButton("Save Settings",self.tab2)
347 | self.save_settings_button.setGeometry(QtCore.QRect(680, 50, 181, 71))
348 | self.save_settings_button.setObjectName("save_settings_button")
349 | self.tree_dir_label = QLabel("Tree Directory",self.tab2)
350 | self.tree_dir_label.setGeometry(QtCore.QRect(10, 50, 81, 21))
351 | self.tree_dir_label.setObjectName("tree_dir_label")
352 | self.vp_tree_dir = QLineEdit(self.tab2)
353 | self.vp_tree_dir.setGeometry(QtCore.QRect(100, 50, 381, 22))
354 | self.vp_tree_dir.setObjectName("VPTreeDir")
355 | self.hash_dir_label = QLabel("Hash Directory",self.tab2)
356 | self.hash_dir_label.setGeometry(QtCore.QRect(10, 80, 81, 21))
357 | self.hash_dir_label.setObjectName("hash_dir_label")
358 | self.hash_dir = QLineEdit(self.tab2)
359 | self.hash_dir.setGeometry(QtCore.QRect(100, 80, 381, 22))
360 | self.hash_dir.setObjectName("hash_dir")
361 | self.index_dir_label = QLabel("Index Directory",self.tab2)
362 | self.index_dir_label.setGeometry(QtCore.QRect(10, 210, 81, 21))
363 | self.index_dir_label.setObjectName("index_dir_label")
364 | self.select_folder_button = QPushButton("Select Folder",self.tab2)
365 | self.select_folder_button.setGeometry(QtCore.QRect(10, 230, 75, 23))
366 | self.select_folder_button.setObjectName("select_folder_button")
367 | self.index_directory = QLineEdit(self.tab2)
368 | self.index_directory.setGeometry(QtCore.QRect(100, 210, 381, 22))
369 | self.index_directory.setObjectName("index_directory")
370 | self.index_go_button = QPushButton("Go",self.tab2)
371 | self.index_go_button.setGeometry(QtCore.QRect(180, 310, 75, 23))
372 | self.index_go_button.setObjectName("index_go_button")
373 | self.index_status = QLabel(self.tab2)
374 | self.index_status.setGeometry(QtCore.QRect(140, 240, 631, 51))
375 | font = QtGui.QFont()
376 | font.setPointSize(14)
377 | self.index_status.setFont(font)
378 | self.index_status.setText("")
379 | self.index_status.setObjectName("index_status")
380 | self.progress_bar = QProgressBar(self.tab2)
381 | self.progress_bar.setGeometry(QtCore.QRect(10, 310, 161, 23))
382 | self.progress_bar.setProperty("value", 0)
383 | self.progress_bar.setRange(0, 100)
384 | self.progress_bar.setObjectName("progress_bar")
385 |
386 | self.label = QLabel("Update Index Database",self.tab2)
387 | self.label.setGeometry(QtCore.QRect(100, 170, 221, 31))
388 | self.label.setObjectName("label")
389 | self.play_sound_check = QCheckBox("Sound",self.tab2)
390 | self.play_sound_check.setGeometry(QtCore.QRect(100, 240, 70, 17))
391 | self.play_sound_check.setObjectName("play_sound_check")
392 |
393 | self.search_range_label = QLabel("Search Range",self.tab2)
394 | self.search_range_label.setGeometry(QtCore.QRect(10, 110, 80, 21))
395 | self.search_range_label.setObjectName("search_range_label")
396 | self.search_range = QSpinBox(self.tab2)
397 | self.search_range.setGeometry(QtCore.QRect(100, 110, 60, 22))
398 | self.search_range.setFixedSize(60, 22)
399 | self.search_range.setObjectName("search_range")
400 | self.default_range_value_label = QLabel("Default: 6",self.tab2)
401 | self.default_range_value_label.setGeometry(QtCore.QRect(180, 110, 210, 21))
402 | self.default_range_value_label.setObjectName("default_range_value_label")
403 | self.on_top_check = QCheckBox("Always On Top",self.tab2)
404 | self.on_top_check.setGeometry(QtCore.QRect(680, 155, 100, 17))
405 | self.on_top_check.setObjectName("OnTopCheck")
406 | self.tab_widget.addTab(self.tab2, "Settings - Index")
407 | self.grid_layout_4.addWidget(self.tab_widget, 0, 0, 1, 1)
408 | self.setCentralWidget(self.central_widget)
409 | self.tab_widget.setCurrentIndex(0)
410 | QtCore.QMetaObject.connectSlotsByName(self)
411 | self.connect_signals()
412 | self.vp_tree_dir.setText(self.settings.value('VPTree'))
413 | self.hash_dir.setText(self.settings.value('Hashing'))
414 | self.search_range.setValue(int(self.settings.value('search_range')))
415 | self.grid_layout_5 = QGridLayout(self.tab3)
416 | self.grid_layout_5.setObjectName("grid_layout_5")
417 | self.log_box = QTextEdit(self.tab3)
418 | self.grid_layout_5.addWidget(self.log_box, 2, 0, 1, 3) # Change column span to 3
419 | self.tab_widget.addTab(self.tab3, "Log")
420 | self.grid_layout_4.addWidget(self.tab_widget, 0, 0, 1, 1)
421 | self.grid_layout_5.setColumnStretch(0, 1)
422 | self.grid_layout_5.setRowStretch(2, 1)
423 | self.log_box.setReadOnly(True)
424 | self.log_box_append("Welcome to the Offline Reverse Image Search Tool!")
425 | self.setCentralWidget(self.central_widget)
426 | self.setWindowIcon(QIcon(get_app_logo()))
427 |
428 | def connect_signals(self) -> None:
429 | """
430 | Connects signals to their respective slots
431 | """
432 | self.next_button.clicked.connect(self.next)
433 | self.previous_button.clicked.connect(self.previous)
434 | self.open_image_button.clicked.connect(self.open_directory)
435 | self.select_folder_button.clicked.connect(self.select_folder_to_index)
436 | self.index_go_button.clicked.connect(self.index_starter)
437 | self.save_settings_button.clicked.connect(self.save_settings)
438 | self.on_top_check.clicked.connect(self.on_top_checker)
439 | self.image_clear_button.clicked.connect(self.clear_image)
440 |
441 | def log_box_append(self, line: str) -> None:
442 | """
443 | Appends text to the log box
444 | """
445 | now = datetime.now()
446 | current_time = now.strftime("%H:%M")
447 | self.log_box.append(f"[{current_time}] {line}")
448 |
449 | @staticmethod
450 | def hamming(a: int, b: int) -> int:
451 | """
452 | Computes the Hamming distance between two integers
453 | """
454 | return bin(int(a) ^ int(b)).count("1")
455 |
456 | def append_colored_text(self, text: str, color: QtGui.QColor) -> None:
457 | """
458 | Appends colored text to the log box
459 | """
460 | now = datetime.now()
461 | current_time = now.strftime("%H:%M")
462 | cursor = self.log_box.textCursor()
463 | cursor.movePosition(QtGui.QTextCursor.MoveOperation.End)
464 | char_format = cursor.charFormat()
465 | char_format.setForeground(QtGui.QBrush(color))
466 | cursor.setCharFormat(char_format)
467 | cursor.insertText(f"[{current_time}] {text}")
468 | cursor.insertBlock()
469 |
470 | def on_top_checker(self) -> None:
471 | """
472 | Sets the window to always on top or not
473 | """
474 | self.setWindowFlags(self.windowFlags() ^ QtCore.Qt.WindowType.WindowStaysOnTopHint)
475 | self.show()
476 |
477 | def update_progress_bar(self, value: int) -> None:
478 | """
479 | Updates the progress bar
480 | """
481 | self.progress_bar.setValue(value)
482 |
483 | def save_settings(self) -> None:
484 | """
485 | Saves the settings to the settings file
486 | """
487 | self.settings.setValue('VPTree',self.vp_tree_dir.text())
488 | self.settings.setValue('Hashing',self.hash_dir.text())
489 | self.settings.setValue('search_range',self.search_range.value())
490 |
491 | def index_starter(self) -> None:
492 | """
493 | Creates a worker thread to run the image indexer
494 | """
495 | #Create a QThread object
496 | self.thread = QtCore.QThread()
497 | #Create a worker object
498 | self.worker = IndexWorker()
499 | #Move worker to the thread
500 | self.worker.moveToThread(self.thread)
501 | #: Connect signals and slots
502 | self.thread.started.connect(self.worker.run)
503 | self.worker.finished.connect(self.thread.quit)
504 | self.worker.finished.connect(self.worker.deleteLater)
505 | self.thread.finished.connect(self.thread.deleteLater)
506 | self.worker.progress.connect(self.update_progress_bar)
507 | #Start the thread
508 | self.thread.start()
509 |
510 | def select_folder_to_index(self) -> None:
511 | """
512 | Opens Window dialog to select folder
513 | """
514 | dialog = QFileDialog.getExistingDirectory(self, "Select Directory")
515 | if dialog:
516 | self.index_directory.setText(dialog)
517 |
518 | def image_indexer(self) -> None:
519 | """
520 | Indexes all images in a folder and creates a VP-Tree
521 | """
522 | # Grab the paths to the input images and initialize the dictionary of hashes
523 | image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.tif')
524 | image_paths = []
525 | self.index_status.setText("Count Images...")
526 |
527 | for root, dirs, files in os.walk(self.index_directory.text()):
528 | for file in files:
529 | if file.lower().endswith(image_extensions):
530 | image_paths.append(os.path.join(root, file))
531 | hashes = {}
532 | # Loop over the image paths
533 |
534 | self.index_go_button.setEnabled(False)
535 | self.select_folder_button.setEnabled(False)
536 |
537 | for (i, image_path) in enumerate(image_paths):
538 | try:
539 | # Load the input image
540 | self.index_status.setText(f"processing image {i+1}/{len(image_paths)}")
541 | if len(image_paths) > 0:
542 | self.worker.progress.emit(((i+1)/len(image_paths))*100)
543 | # Compute the hash for the image and convert it
544 | h = dhash(image_path)
545 | h = convert_hash(h)
546 | # Update the hashes dictionary
547 | l = hashes.get(h, [])
548 | l.append(image_path)
549 | hashes[h] = l
550 | except FileNotFoundError:
551 | logging.error("File not found: %s", image_path, exc_info=True)
552 | self.append_colored_text(f"{image_path} Failed: File not found", QtGui.QColor("red"))
553 | except Exception as e:
554 | logging.error("Error processing image: %s", image_path, exc_info=True)
555 | self.append_colored_text(f"{image_path} Failed: {str(e)}", QtGui.QColor("red"))
556 |
557 | if not hashes:
558 | self.index_status.setText("No Images found in folder")
559 | self.log_box_append("No Images found in folder")
560 | self.worker.progress.emit(0)
561 | self.index_go_button.setEnabled(True)
562 | self.select_folder_button.setEnabled(True)
563 | SuccessSound()
564 | return
565 | # Load & add existing hashes/dirs
566 | if os.path.isfile(self.hash_dir.text()):
567 | with open(self.hash_dir.text(), "rb") as hash_file:
568 | hashes.update(pickleloads(hash_file.read()))
569 | else:
570 | self.log_box_append("Hashes file not found. Creating new one")
571 | with open(self.hash_dir.text(), "wb") as hash_file:
572 | pickledump(hashes, hash_file)
573 | # build the VP-Tree
574 | self.index_status.setText("[INFO] building VP-Tree...")
575 | points = list(hashes.keys())
576 | tree = VPTree(points, self.hamming)
577 | # serialize the VP-Tree to disk
578 | self.index_status.setText("[INFO] serializing VP-Tree...")
579 | with open(self.vp_tree_dir.text(), 'wb') as vp_tree_file:
580 | vp_tree_file.write(pickledumps(tree))
581 | with open(self.hash_dir.text(), 'wb') as hashes_file:
582 | hashes_file.write(pickledumps(hashes))
583 | self.log_box_append(f"{len(hashes)} Images Indexed")
584 | self.index_status.setText('Finished')
585 | if self.play_sound_check.isChecked():
586 | SuccessSound()
587 | self.index_go_button.setEnabled(True)
588 | self.select_folder_button.setEnabled(True)
589 | self.worker.progress.emit(0)
590 | self.worker.finished.emit()
591 |
592 | def worker_thread(self) -> None:
593 | """
594 | Creates a worker thread to run the image search
595 | """
596 | # If a thread is already running, stop it
597 | if hasattr(self, 'thread'):
598 | try:
599 | if isinstance(self.thread, QtCore.QThread) and self.thread.isRunning():
600 | self.thread.quit()
601 | self.thread.wait()
602 | except RuntimeError:
603 | # The thread was already deleted, so we can ignore this error
604 | pass
605 | finally:
606 | self.thread = None # Clear the reference to the old thread
607 |
608 | #Create a QThread object
609 | self.thread = QtCore.QThread()
610 | #Create a worker object
611 | self.worker = Worker(self.images_found, self.file_path, self.photo_main, self.photo_viewer, self.next_button, self.previous_button, self.open_image_button, self.label_result, self.label_current, self.set_image, self.append_colored_text, self.display_queue, self.current, self.settings)
612 | #Move worker to the thread
613 | self.worker.moveToThread(self.thread)
614 | #Connect signals and slots
615 | self.thread.started.connect(self.worker.run)
616 | self.worker.finished.connect(self.thread.quit)
617 | self.worker.finished.connect(self.worker.deleteLater)
618 | self.thread.finished.connect(self.thread.deleteLater)
619 | #Start the thread
620 | self.thread.start()
621 |
622 | def dragEnterEvent(self, event: QtGui.QDragEnterEvent):
623 | """
624 | Accepts the drag event if it has an image
625 | """
626 | if event.mimeData().hasImage:
627 | event.accept()
628 | else:
629 | event.ignore()
630 |
631 | def open_directory(self) -> None:
632 | """
633 | Opens the directory of the current image
634 | """
635 | if not self.current:
636 | self.append_colored_text("[ERROR] No image found", QtGui.QColor("red"))
637 | ErrorSound()
638 | return
639 |
640 | matches = findall(r'\A.*\\', self.current)
641 | if matches:
642 | x = matches[0]
643 |
644 | # Open the directory in the file explorer based on the OS
645 | if sys.platform.startswith('win'):
646 | subprocess.Popen(["explorer", x])
647 | elif sys.platform.startswith('darwin'):
648 | subprocess.Popen(["open", x])
649 | else:
650 | subprocess.Popen(["xdg-open", x])
651 | else:
652 | self.append_colored_text("[ERROR] No directory found in the path", QtGui.QColor("red"))
653 | ErrorSound()
654 |
655 | def previous(self) -> None:
656 | """
657 | Displays the previous image in the queue
658 | """
659 | self.display_queue.rotate(1)
660 | self.current = self.display_queue[0]
661 | self.label_current.setText('Current '+str(self.images_found.index(self.current)+1)+'/'+str(len(self.images_found)))
662 | self.set_image(self.current)
663 | if self.images_found.index(self.current) == 0:
664 | self.previous_button.setEnabled(False)
665 | elif not self.next_button.isEnabled() and self.images_found.index(self.current) != len(self.images_found):
666 | self.next_button.setEnabled(True)
667 |
668 | def next(self) -> None:
669 | """
670 | Displays the next image in the queue
671 | """
672 | self.display_queue.rotate(-1)
673 | self.current = self.display_queue[0]
674 | self.label_current.setText('Current '+str(self.images_found.index(self.current)+1)+'/'+str(len(self.images_found)))
675 | self.set_image(self.current)
676 | if self.images_found.index(self.current)+1 == len(self.images_found):
677 | self.next_button.setEnabled(False)
678 | elif not self.previous_button.isEnabled() and self.images_found.index(self.current) != 0:
679 | self.previous_button.setEnabled(True)
680 |
681 | def dragMoveEvent(self, event: QtGui.QDragMoveEvent):
682 | """
683 | Accepts the drag event
684 | """
685 | if event.mimeData().hasUrls():
686 | event.setDropAction(QtCore.Qt.DropAction.CopyAction)
687 | event.accept()
688 | else:
689 | event.ignore()
690 |
691 | def clear_image(self) -> None:
692 | """
693 | Clears the image and resets the UI
694 | """
695 | self.photo_viewer.clear()
696 | self.photo_main.clear()
697 | self.photo_viewer.setText('Place New Image')
698 | self.label_result.setText('')
699 | self.label_current.setText('')
700 | self.next_button.setEnabled(False)
701 | self.previous_button.setEnabled(False)
702 | self.open_image_button.setEnabled(False)
703 | if os.path.isfile(f'{self.dir_path}testfile.{self.test_file_ext}'):
704 | os.remove(f'{self.dir_path}testfile.{self.test_file_ext}')
705 |
706 | def dropEvent(self, event: QtGui.QDropEvent):
707 | """
708 | Handles the drop event
709 | """
710 | print('resetting image.')
711 | print('current file path:',self.current)
712 | self.clear_image()
713 | if event.mimeData().html():
714 | url = findall('src="(http.*?\\..+?)"', event.mimeData().html())[0]
715 | print('url:',url)
716 |
717 | # Download the image file from the URL
718 | response = requestsGet(url, timeout=15)
719 | image_data = BytesIO(response.content)
720 |
721 | # Open the image file using Pillow to get the file format
722 | image = Image.open(image_data)
723 | self.test_file_ext = image.format.lower()
724 |
725 | # Save the image file with the correct file extension
726 | file_path = f"{self.dir_path}testfile.{self.test_file_ext}"
727 | # Set the file path as an instance variable
728 | self.file_path = file_path
729 | event.accept()
730 | logging.info('File path set as instance variable: %s', self.file_path)
731 | self.worker_thread()
732 | try:
733 | url = event.mimeData().urls()[0].toLocalFile()
734 | except IndexError:
735 | self.photo_viewer.setText('No file or image found')
736 | ErrorSound()
737 | event.ignore()
738 | self.append_colored_text("[ERROR] No file or image found", QtGui.QColor("red"))
739 | return
740 |
741 | if url:
742 | self.file_path = url
743 | if self.file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.tiff', '.bmp', '.gif')):
744 | event.accept()
745 | self.worker_thread()
746 | else:
747 | self.photo_viewer.setText('File format not supported')
748 | logging.warning('Mime data:\n%s', event.mimeData().text())
749 | ErrorSound()
750 | event.ignore()
751 | self.append_colored_text("[ERROR] File format not supported", QtGui.QColor("red"))
752 | else:
753 | self.photo_viewer.setText('No file or image found')
754 | ErrorSound()
755 | event.ignore()
756 | self.append_colored_text("[ERROR] No file or image found", QtGui.QColor("red"))
757 |
758 | def set_image(self, path: str) -> None:
759 | """
760 | Sets the image to the photo viewer
761 | """
762 | if path and os.path.isfile(path):
763 | self._current_image_path = path
764 | pixmap = QPixmap(path).scaled(
765 | self.photo_viewer.width(),
766 | self.photo_viewer.height(),
767 | QtCore.Qt.AspectRatioMode.KeepAspectRatio,
768 | QtCore.Qt.TransformationMode.SmoothTransformation,
769 | )
770 | self.photo_viewer.setPixmap(pixmap)
771 | else:
772 | self.append_colored_text("[ERROR] Image file not found", QtGui.QColor("red"))
773 | self.photo_viewer.setText("Image file not found")
774 | ErrorSound()
775 |
776 | def get_test_file_ext(self) -> str:
777 | """
778 | Returns the file extension of the test file.
779 |
780 | Returns:
781 | str: The file extension of the test file.
782 | """
783 | return self.test_file_ext
784 |
785 |
786 | if __name__ == "__main__":
787 |
788 | app = QApplication(sys.argv)
789 | splash = SplashScreen()
790 | splash.show()
791 | window = MyWindow()
792 | window.show()
793 | QtCore.QTimer.singleShot(1200, splash.close)
794 | app.processEvents()
795 | exit_code = app.exec()
796 |
797 | if window.get_test_file_ext():
798 | try:
799 | os.remove(rf'{os.getcwd()}\testfile.{window.test_file_ext}')
800 | except FileNotFoundError:
801 | logging.error("File not found: %s", rf'{os.getcwd()}\testfile.{window.test_file_ext}', exc_info=True)
802 | except PermissionError:
803 | logging.error("Permission denied: %s", rf'{os.getcwd()}\testfile.{window.test_file_ext}', exc_info=True)
804 | except Exception as e:
805 | logging.error("Error deleting file: %s", rf'{os.getcwd()}\testfile.{window.test_file_ext}', exc_info=True)
806 | logging.error("Error: %s", str(e))
807 | sys.exit(exit_code)
808 |
--------------------------------------------------------------------------------