├── .gitignore ├── requirements.txt ├── images └── example-before-after.jpg ├── docker-compose.yml ├── Dockerfile ├── README.md └── rotate.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Environments 2 | venv 3 | 4 | # IDE 5 | .idea 6 | 7 | # Mac 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==7.1.2 2 | dlib==19.20.0 3 | numpy==1.19.1 4 | opencv-python==4.3.0.36 5 | Pillow==7.2.0 6 | -------------------------------------------------------------------------------- /images/example-before-after.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsposito/auto-image-rotator/HEAD/images/example-before-after.jpg -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | app: 5 | container_name: auto-image-rotator-app 6 | image: auto-image-rotator-app 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | command: python rotate.py ${OVERWRITE_FILES:-0} 11 | volumes: 12 | - .:/app 13 | - ${IMAGES_PATH}:/images 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | # Update package lists and install CMake (for compiling Dlib). 4 | RUN apt-get -y update && apt-get -y install build-essential cmake 5 | 6 | # Set the container's working directory. 7 | WORKDIR /app 8 | 9 | # Copy Python dependencies list and install. 10 | # Note: this is done prior to copying all project files to maximize cache hits. 11 | COPY requirements.txt /app/ 12 | RUN pip install -r requirements.txt 13 | 14 | # Copy the current directory contents into the container's working directory. 15 | COPY . /app 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto Image Rotator 2 | 3 | ## Overview 4 | This app uses the OpenCV and Dlib computer vision libraries to auto rotate images based on detected human faces. 5 | 6 | *This is useful for auto rotating images in bulk that do not contain EXIF orientation meta data (e.g., scanned photos).* 7 | 8 | Currently, this is only effective for images that contain one or more face. In the future, [advanced CNN techniques](https://d4nst.github.io/2017/01/12/image-orientation/) could be implemented to auto correct the rotation for any photo. 9 | 10 | ![title](images/example-before-after.jpg) 11 | 12 | ## Setup 13 | 14 | #### Docker 15 | 1. Install [Docker](https://www.docker.com/get-started) so we can build and run the app 16 | 17 | 2. Build the app's Docker image (this takes a few minutes to complete while it builds Dlib's native C extension for Python): 18 | ``` 19 | docker-compose build app 20 | ``` 21 | 22 | ## Usage 23 | After the one-time setup, rotating a directory of images is as simple as running this command: 24 | 25 | ``` 26 | IMAGES_PATH=/path/to/your/images/folder docker-compose run app 27 | ``` 28 | 29 | By default, rotated images are saved as new files with a `*-rotated` filename pattern in your `IMAGES_PATH` directory. If you're comfortable overwriting your original files with rotated versions you may prefix the command with the `OVERWRITE_FILES` param like so: 30 | 31 | ``` 32 | IMAGES_PATH=/path/to/your/images/folder OVERWRITE_FILES=1 docker-compose run app 33 | ``` 34 | -------------------------------------------------------------------------------- /rotate.py: -------------------------------------------------------------------------------- 1 | import click 2 | import cv2 3 | import dlib 4 | import numpy as np 5 | import os 6 | 7 | from pathlib import Path 8 | from PIL import Image, ImageFile 9 | 10 | 11 | class Rotator: 12 | IMAGES_DIRECTORY = "/images" 13 | 14 | def __init__(self, overwrite_files: bool=False): 15 | self.detector = dlib.get_frontal_face_detector() 16 | self.overwrite_files =overwrite_files 17 | 18 | def analyze_images(self): 19 | # Recursively loop through all files and subdirectories. 20 | # os.walk() is a recursive generator. 21 | # The variable "root" is dynamically updated as walk() recursively traverses directories. 22 | images = [] 23 | for root_dir, sub_dir, files in os.walk(self.IMAGES_DIRECTORY): 24 | for file_name in files: 25 | if file_name.lower().endswith((".jpeg", ".jpg", ".png")): 26 | file_path = str(os.path.join(root_dir, file_name)) 27 | images.append(file_path) 28 | 29 | # Analyze each image file path - rotating when needed. 30 | rotations = {} 31 | with click.progressbar(images, label=f"Analyzing {len(images)} Images...") as filepaths: 32 | for filepath in filepaths: 33 | image = self.open_image(filepath) 34 | rotation = self.analyze_image(image, filepath) 35 | 36 | if rotation: 37 | rotations[filepath] = rotation 38 | 39 | print(f"{len(rotations)} Images Rotated") 40 | for filepath, rotation in rotations.items(): 41 | print(f" - {filepath} (Rotated {rotation} Degrees)") 42 | 43 | def analyze_image(self, image: ImageFile, filepath: str) -> int: 44 | """Cycles through 4 image rotations of 90 degrees. 45 | Saves the image at the current rotation if faces are detected. 46 | """ 47 | 48 | for cycle in range(0, 4): 49 | if cycle > 0: 50 | # Rotate the image an additional 90 degrees for each non-zero cycle. 51 | image = image.rotate(90, expand=True) 52 | 53 | image_copy = np.asarray(image) 54 | image_gray = cv2.cvtColor(image_copy, cv2.COLOR_BGR2GRAY) 55 | 56 | faces = self.detector(image_gray, 0) 57 | if len(faces) == 0: 58 | continue 59 | 60 | # Save the image only if it has been rotated. 61 | if cycle > 0: 62 | self.save_image(image, filepath) 63 | return cycle * 90 64 | 65 | return 0 66 | 67 | def open_image(self, filepath: str) -> ImageFile: 68 | """Intentionally opens an image file using Pillow. 69 | If opened with OpenCV, the saved image is a much larger file size than the original 70 | (regardless of whether saved via OpenCV or Pillow). 71 | """ 72 | 73 | return Image.open(filepath) 74 | 75 | def save_image(self, image: ImageFile, filepath: str) -> bool: 76 | """Saves the rotated image using Pillow.""" 77 | 78 | if not self.overwrite_files: 79 | filepath = filepath.replace(".", "-rotated.", 1) 80 | 81 | try: 82 | image.save(filepath) 83 | return True 84 | except: 85 | return False 86 | 87 | 88 | @click.command() 89 | @click.argument("overwrite_files", type=click.BOOL, default=False) 90 | def cli(overwrite_files: bool=False): 91 | rotator = Rotator(overwrite_files) 92 | rotator.analyze_images() 93 | 94 | 95 | if __name__ == "__main__": 96 | cli() 97 | --------------------------------------------------------------------------------