├── .gitignore ├── README.md ├── common ├── calculations.py ├── hash.py ├── ml.py └── preprocess.py ├── deprecated-scripts ├── adobe-lightroom.py ├── adobe_lightroom_config_sample.py └── apple-photos.py ├── main.py ├── mlmodels └── BlurDetection.mlmodel └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .DS_Store 7 | 8 | # Custom 9 | .idea 10 | .idea/ 11 | adobe-lightroom/config.py 12 | adobe_lightroom_config.py 13 | 14 | # C extensions 15 | *.so 16 | 17 | # media 18 | media/ 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 106 | __pypackages__/ 107 | 108 | # Celery stuff 109 | celerybeat-schedule 110 | celerybeat.pid 111 | 112 | # SageMath parsed files 113 | *.sage.py 114 | 115 | # Environments 116 | .env 117 | .venv 118 | env/ 119 | venv/ 120 | ENV/ 121 | env.bak/ 122 | venv.bak/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | 142 | 143 | /photos -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lightroom Blur 2 | ## Automatically clean up blurry photos 3 | -------------------------------------------------------------------------------- /common/calculations.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy 3 | 4 | 5 | def variance_of_laplacian(image): 6 | # compute the Laplacian of the image and then return the focus 7 | # measure, which is simply the variance of the Laplacian 8 | opencv_image = cv2.cvtColor(numpy.array(image), cv2.COLOR_RGB2BGR) 9 | gray = cv2.cvtColor(opencv_image, cv2.COLOR_BGR2GRAY) 10 | return cv2.Laplacian(gray, cv2.CV_64F).var() 11 | 12 | 13 | def variance_of_laplacian_quadrants(image): 14 | height, width = image.size 15 | 16 | # left, top, right, bottom 17 | top_left_quad = image.crop((0, 0, width / 2, height / 2)) 18 | top_right_quad = image.crop((width / 2, 0, width, height / 2)) 19 | bottom_left_quad = image.crop((0, height / 2, width / 2, height)) 20 | bottom_right_quad = image.crop((width / 2, height / 2, width, height)) 21 | center = image.crop((width / 8, height / 8, (width / 8) * 7, (height / 8) * 7)) 22 | 23 | quads = [top_left_quad, top_right_quad, bottom_left_quad, bottom_right_quad, center] 24 | 25 | sum = 0 26 | vols = '' 27 | for quad in quads: 28 | vol = variance_of_laplacian(quad) 29 | sum += vol 30 | vols = vols + str(vol) + ' ' 31 | 32 | avg = sum / len(quads) 33 | 34 | return avg -------------------------------------------------------------------------------- /common/hash.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import numpy 3 | 4 | def img_hash(img, hashsize=8): 5 | # convert the image to grayscale so the hash is only on one channel 6 | opencv_image = cv2.cvtColor(numpy.array(img), cv2.COLOR_RGB2BGR) 7 | gray = cv2.cvtColor(opencv_image, cv2.COLOR_BGR2GRAY) 8 | # resize the input image, adding a single column (width) so we 9 | # can compute the horizontal gradient 10 | resized = cv2.resize(gray, (hashsize + 1, hashsize)) 11 | # compute the (relative) horizontal gradient between adjacent 12 | # column pixels 13 | diff = resized[:, 1:] > resized[:, :-1] 14 | # convert the difference image to a hash 15 | return sum([2 ** i for (i, v) in enumerate(diff.flatten()) if v]) -------------------------------------------------------------------------------- /common/ml.py: -------------------------------------------------------------------------------- 1 | import coremltools as ct 2 | 3 | def load_model(): 4 | # Load the Image Model 5 | model = ct.models.MLModel('common/BlurDetection.mlmodel') 6 | return model 7 | 8 | def predict(model, image): 9 | return model.predict({'image': image}) 10 | 11 | def get_prediction_result(prediction): 12 | return prediction['classLabel'] -------------------------------------------------------------------------------- /common/preprocess.py: -------------------------------------------------------------------------------- 1 | import PIL 2 | import numpy 3 | 4 | 5 | def preprocess_img_for_ml_model(img): 6 | #img = img.resize((299, 299), PIL.Image.NEAREST) # Best Speed 7 | img = img.resize((299, 299), PIL.Image.LANCZOS) # Best Quality 8 | img_np = numpy.array(img).astype(numpy.float32) 9 | return img_np, img -------------------------------------------------------------------------------- /deprecated-scripts/adobe-lightroom.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | from common.calculations import variance_of_laplacian, variance_of_laplacian_quadrants 4 | from common.hash import img_hash 5 | from common.ml import load_model, predict 6 | from common.preprocess import preprocess_img_for_ml_model 7 | import adobe_lightroom_config as config 8 | from io import BytesIO 9 | 10 | import requests 11 | from PIL import Image 12 | from selenium import webdriver 13 | from selenium.webdriver.common.keys import Keys 14 | 15 | WAIT_TIME = 2 16 | 17 | 18 | def main(): 19 | print('lightroom-blur') 20 | # Load the Image Model 21 | model = load_model() 22 | print('Loaded ML model') 23 | 24 | # Setup the web driver 25 | driver = webdriver.Safari() 26 | driver.get('https://lightroom.adobe.com/signin') 27 | driver.set_window_size(2000, 1200) 28 | print('Loaded webdriver') 29 | 30 | time.sleep(WAIT_TIME) 31 | 32 | # Login 33 | email_field = driver.find_element_by_xpath('//*[@id="EmailPage-EmailField"]') 34 | email_field.send_keys(config.LIGHTROOM_EMAIL) 35 | time.sleep(WAIT_TIME) 36 | continue_button = driver.find_element_by_xpath('//*[@id="EmailForm"]/section[2]/div[2]/button/span').click() 37 | time.sleep(WAIT_TIME) 38 | password_field = driver.find_element_by_xpath('//*[@id="PasswordPage-PasswordField"]') 39 | password_field.send_keys(config.LIGHTROOM_PASSWORD) 40 | continue_button = driver.find_element_by_xpath('//*[@id="PasswordForm"]/section[2]/div[2]/button/span').click() 41 | try: 42 | driver.find_element_by_xpath('//*[@id="App"]/div/div/section/div/div/section/div/section/div/div/section/footer/div/button/span').click() # Look for a 'Want to sign in without your password?' screen and then clck 'Skip and Continue' 43 | except: 44 | pass 45 | print('Finished login process') 46 | time.sleep(WAIT_TIME * 2) 47 | enable_cookies_button = driver.find_element_by_xpath('//*[@id="onetrust-accept-btn-handler"]').click() 48 | time.sleep(WAIT_TIME) 49 | 50 | # Click into the all photos tab 51 | all_photos_button = driver.find_element_by_xpath('//*[@id="ze-sidebar-all-photos"]/div[1]/span[2]').click() 52 | 53 | # Click on the first image to expand it 54 | first_image_tile = driver.find_elements_by_class_name('image')[0].click() 55 | time.sleep(WAIT_TIME) 56 | 57 | # Set requests cookies from webdriver 58 | # These allow us to access the current photo directly from a python web request 59 | s = requests.Session() 60 | session_counter = 0 61 | for cookie in driver.get_cookies(): 62 | session_counter += 1 63 | s.cookies.set(cookie['name'], cookie['value']) 64 | print(f'Set {session_counter} cookies') 65 | 66 | # Loop through the catalog of images 67 | # Find the number of images 68 | time.sleep(WAIT_TIME) 69 | countlabel = driver.find_element_by_class_name('countlabel').text 70 | num_images = int(countlabel.split(' ')[2]) 71 | print(f'Found {num_images} files') 72 | 73 | # initialize some stats 74 | time_start = time.time() 75 | num_processed = 0 76 | num_blurred = 0 77 | num_duplicate = 0 78 | images = {} 79 | 80 | print('Started processing images') 81 | # for _ in range(num_images): 82 | for _ in range(200): 83 | # Find the correct image 84 | div_tag = driver.find_element_by_class_name('ze-active') 85 | play_icon = div_tag.find_element_by_class_name('play') 86 | if play_icon.get_attribute('style') != 'display: none;': 87 | print(f'{"video":11s} {round(1.00, 2) * 100:6.2f}%') 88 | pass # Pass since it is a video, and we don't process videos 89 | else: 90 | num_processed += 1 91 | img = div_tag.find_element_by_tag_name('img') 92 | img_src = img.get_attribute('src') 93 | response = s.get(img_src) 94 | 95 | # Convert the response data into a PIL image 96 | pil_img = Image.open(BytesIO(response.content)).convert('RGB') 97 | 98 | # Compute the hash of the image 99 | hash = img_hash(pil_img) 100 | if hash in images: 101 | num_duplicate += 1 102 | # driver.find_element_by_xpath('//*[@id="sneaky-loupe-rating"]/div/div[1]').click() # Click the 1 star button so I can find duplicates 103 | else: 104 | images[hash] = pil_img 105 | 106 | # Make Prediction with Ml model 107 | _, img = preprocess_img_for_ml_model(pil_img) 108 | prediction_result = predict(model, img) 109 | print(f'{prediction_result.get("classLabel"):11s} {round(prediction_result.get("classLabelProbs").get(prediction_result.get("classLabel")), 2) * 100:6.2f}%') 110 | 111 | if prediction_result.get('classLabel') == 'blurred': 112 | num_blurred += 1 113 | # driver.find_element_by_xpath('//*[@id="sneaky-loupe-flag"]/div[2]').click() # Click the flag as reject button 114 | 115 | # Make decision based on V o l quadrants 116 | quadrants = variance_of_laplacian_quadrants(pil_img) 117 | 118 | # Make decision based on V o l 119 | vol = variance_of_laplacian(pil_img) 120 | 121 | # print(f'Quads: {quadrants} VoL: {vol}') 122 | descriptor = '' 123 | if vol < 100: 124 | descriptor = 'BLUR' 125 | # pil_img.save(f'/Users/aaronspindler/Desktop/lightroom-blur/images/blur/{uuid.uuid4().hex}.jpg', 'JPEG') # Saves the blurry image to a folder with unique filename 126 | 127 | # Press the right key to move to the next image 128 | driver.find_element_by_xpath('/html/body/sp-theme').send_keys(Keys.RIGHT) 129 | 130 | time_end = time.time() 131 | time_elapsed = round(time_end - time_start, 2) 132 | print(f'Processed {num_processed} images in {time_elapsed} seconds ({round(num_processed / time_elapsed, 2)} img/s)') 133 | 134 | print(f'{num_blurred} blurry pictures') 135 | print(f'{num_duplicate} duplicate pictures') 136 | 137 | 138 | if __name__ == '__main__': 139 | main() 140 | -------------------------------------------------------------------------------- /deprecated-scripts/adobe_lightroom_config_sample.py: -------------------------------------------------------------------------------- 1 | LIGHTROOM_EMAIL = '' 2 | LIGHTROOM_PASSWORD = '' 3 | -------------------------------------------------------------------------------- /deprecated-scripts/apple-photos.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import osxphotos 3 | from PIL import Image 4 | from common.ml import load_model, predict 5 | 6 | from common.preprocess import preprocess_img_for_ml_model 7 | 8 | def main(): 9 | """ 10 | This script has been shelved since there are limitations from apple on how unregistered applications can access and manipulate the photos library. 11 | Until this is resolved, this script will not be developed any further. 12 | """ 13 | 14 | 15 | # SETTINGS 16 | ignore_certain_extensions = True 17 | # Current Possible Extensions 18 | # {'.jpeg': 56632, '.arw': 23449, '.dng': 5517, '.heic': 4841, '.png': 2237, '.JPG': 179, '.bmp': 5, '.webp': 1} 19 | excluded_extensions = ['.arw', '.heic', '.bmp', '.webp'] 20 | 21 | 22 | print('Connecting to DB') 23 | photosdb = osxphotos.PhotosDB(osxphotos.utils.get_last_library_path()) 24 | print('Loading Photos') 25 | photos = photosdb.query(osxphotos.QueryOptions(not_missing=True, movies=False, photos=True)) 26 | print('Loaded {} photos'.format(len(photos))) 27 | 28 | fingerprints = {} 29 | 30 | model = load_model() 31 | 32 | for photo in photos: 33 | path = photo.path 34 | extension = pathlib.Path(path).suffix 35 | if extension in excluded_extensions and ignore_certain_extensions: 36 | continue 37 | fingerprint = photo.fingerprint 38 | if fingerprint != None: 39 | if fingerprint not in fingerprints: 40 | fingerprints[fingerprint] = [] 41 | fingerprints[fingerprint].append(photo) 42 | 43 | print('Creating Album') 44 | album = osxphotos.PhotosAlbum('Bad Photos') 45 | print('Created Album') 46 | bad_photos = [] 47 | # print out fingerprints with more than one photo 48 | for fingerprint in fingerprints[:1]: 49 | if len(fingerprints[fingerprint]) > 1: 50 | print('Fingerprint: {}'.format(fingerprint)) 51 | is_first = True 52 | for photo in fingerprints[fingerprint]: 53 | print(' {}'.format(photo.path)) 54 | if is_first: 55 | is_first = False 56 | continue 57 | bad_photos.append(photo) 58 | print() 59 | 60 | album.add_list(bad_photos) 61 | 62 | 63 | # image = Image.open(photo.path) 64 | # pp_img_np, pp_img = preprocess_img_for_ml_model(image) 65 | # print(predict(model, pp_img)) 66 | 67 | if __name__ == '__main__': 68 | main() 69 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronspindler/lightroom/746aad67d078101b8d072235b5d7327b6442bce0/main.py -------------------------------------------------------------------------------- /mlmodels/BlurDetection.mlmodel: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronspindler/lightroom/746aad67d078101b8d072235b5d7327b6442bce0/mlmodels/BlurDetection.mlmodel -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==2.2.6 2 | opencv-python==4.12.0.88 3 | pillow==11.3.0 4 | prompt_toolkit==3.0.52 5 | wcwidth==0.2.14 6 | --------------------------------------------------------------------------------