├── .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 | --------------------------------------------------------------------------------