Home (All Topics) → Technology → Software → AI-Created Software
The following is AI-generated (aka vibe-coded) software. The software is specific to my needs and my system and may not work for you or your system. Support is limited, but if you report a bug I'll certainly look into it. All code is provided in a zip file for you to review should you choose.
Batch-crop scanned photos by removing the solid-color border left by the scanner bed. The script can also straighten photos that were placed at a slight angle, split a scan containing several prints into separate files, and preserve the metadata that matters for archival scans: EXIF, color profile, and DPI.
No other dependencies (OpenCV, SciPy, etc.) are needed.
This crops every supported image in scans that has a white border and writes the results to cropped with the same filenames. The originals are never modified unless you explicitly allow it (see Safety).
It’s a good idea to try new settings on a copy of a few scans first, or to use --dry-run to see what would happen without writing anything.
Photos scanned slightly crooked
Several prints on each scan
python auto-crop-photo-borders.py --input-folder-path scans --output-folder-path cropped --split --deskew
A scan named album-03.jpg with three prints produces album-03_1.jpg, album-03_2.jpg, and album-03_3.jpg, numbered in reading order (top to bottom, left to right).
Border isn’t white, or varies between scans
python auto-crop-photo-borders.py --input-folder-path scans --output-folder-path cropped --color-hex auto
A dark line along the edge of every scan (a shadow from the scanner lid)
python auto-crop-photo-borders.py --input-folder-path scans --output-folder-path cropped --ignore-edge 5
A large archive with subfolders, using all CPU cores, with a log
python auto-crop-photo-borders.py --input-folder-path archive --output-folder-path archive-cropped \
--recursive --workers 0 --skip-existing --log-csv crop-log.csv
--skip-existing makes it safe to stop and restart a long run: files that already have output are skipped.
Remove a thin blended edge left after straightening
python auto-crop-photo-borders.py --input-folder-path scans --output-folder-path cropped --deskew --padding -2
--color-hex auto, the border color is the median color of a thin strip around the scan’s edges.--denoise pixels are ignored.--fill-threshold percent of pixels are border. The percentage is measured only across the part of the image that remains, so a small photo on a large scanner bed is found correctly, and stray marks near the edges don’t prevent a row or column from being trimmed.--deskew). The script traces the photo’s outline, finds the rotation that fits it into the smallest rectangle, rotates the photo, fills the exposed corners with the border color, and detects the crop again.--split). Separate groups of non-border pixels are treated as separate photos. Groups closer together than --split-merge-distance are joined, and groups smaller than --split-min-area (dust, scratches) are ignored. Each photo is then straightened and cropped on its own, with neighbouring photos masked out.--padding and --max-crop, crop, and save.
| Option | Default | Description |
|---|---|---|
--input-folder-path PATH |
required | Folder containing the scans. |
--output-folder-path PATH |
required | Folder for the cropped images. Created if it doesn’t exist. |
--recursive |
off | Also process subfolders. The folder structure is recreated in the output folder. If the output folder is inside the input folder, it is excluded from the scan. |
| Option | Default | Description |
|---|---|---|
--color-hex COLOR |
FFFFFF |
Border color as hex (#FFFFFF or FFFFFF), or auto to detect it for each scan. |
--tolerance 0-100 |
10 |
How far each color channel may differ from the border color. 0 is an exact match; 10 allows ±26 on the 0–255 scale. Raise it for noisy or unevenly lit scans. |
--fill-threshold 1-100 |
80 |
Percentage of a row or column that must be border for it to be trimmed. Lower values crop more aggressively. |
--denoise N |
3 |
Ignore dust specks up to about N pixels across. 0 turns this off. |
--ignore-edge N |
0 |
Ignore N pixels along every edge of the scan. Useful for dark shadow lines or scanner artefacts at the very edge. |
| Option | Default | Description |
|---|---|---|
--padding N |
0 |
Keep N pixels of border around the photo. A negative value trims N pixels into the photo instead, which removes any soft edge. |
--max-crop PERCENT |
100 |
Never remove more than this percentage of the width or height from any one side. 100 means no limit. |
| Option | Default | Description |
|---|---|---|
--deskew |
off | Straighten photos that were scanned at an angle. |
--max-skew DEGREES |
10 |
Largest tilt to look for (up to 45). |
--min-skew DEGREES |
0.1 |
Photos tilted less than this are left unrotated, avoiding an unnecessary resample. |
| Option | Default | Description |
|---|---|---|
--split |
off | Detect and save each photo on a scan separately. If only one photo is found, the output uses the original filename. |
--split-min-area PERCENT |
2 |
Ignore objects smaller than this percentage of the scan’s area. |
--split-merge-distance PERCENT |
1 |
Objects closer together than this percentage of the scan’s shorter side are treated as one photo. Increase it if one photo gets split into pieces; decrease it if photos placed close together are kept as one. |
| Option | Default | Description |
|---|---|---|
--quality 1-100 or keep |
95 |
JPEG and WEBP quality. keep reuses the source JPEG’s compression settings. For WEBP output, keep saves losslessly. |
--skip-existing |
off | Skip scans whose output file already exists. In split mode, the check is for name.ext or name_1.ext. |
--allow-overwrite |
off | Allow the output folder to be the input folder. Originals will be replaced. |
| Option | Default | Description |
|---|---|---|
--workers N |
1 |
Number of scans to process at the same time. 0 uses one per CPU core. |
--dry-run |
off | Analyse every scan and print what would happen, without writing any images. |
--log-csv PATH |
none | Write a CSV file with the details of every image processed. |
Each line shows the file, a status, and what was done:
[1/9] brightsky.png OK border #FAFAFA; cropped top=250px, bottom=152px, left=152px, right=152px
! crop limited by --max-crop 25%
[2/9] broken.jpg SKIP cannot open: Truncated File Read
[8/9] sub/multi.jpg OK border #FAFAFA; split into 3 photos
-> multi_1.jpg 1098x748, rotated -1.50°
-> multi_2.jpg 1098x748, rotated +3.00°
-> multi_3.jpg 1098x748, rotated -0.80°
[9/9] tilted.tif OK rotated -2.50°; 1800x1400 -> 1198x798
--skip-existing, or saving would overwrite the input.! are warnings worth checking.The script exits with code 1 if any file had an error, and 0 otherwise, so it can be used in scripts and scheduled tasks.
| Column | Meaning |
|---|---|
input, output |
Source file (relative to the input folder) and output path. Split scans get one row per photo. |
status, message |
Same as the console output. |
region_left … region_bottom |
In split mode, the area of the scan this photo was taken from. |
rotation_deg |
Rotation applied, in degrees counter-clockwise. 0.0 means not rotated. |
crop_left … crop_bottom |
The crop box. If the photo was rotated or split, the coordinates refer to the rotated or split-out image, not the original scan. Empty means nothing was cropped. |
output_width, output_height |
Final size in pixels. |
notes |
Warnings for that image. |
Supported formats: JPEG, PNG, TIFF, BMP, and WEBP. Each output uses the same format and filename as its source.
| What | How it’s handled |
|---|---|
| JPEG quality | Saved at quality 95 with full color resolution (no chroma subsampling) by default, instead of Pillow’s default of 75. |
| EXIF | Kept for JPEG, PNG, and WEBP sources. The orientation tag is reset to “normal”, because the rotation has already been applied to the pixels. |
| ICC color profile | Kept, except when the image has to change color type for the output format (for example, CMYK saved as PNG), where the old profile would no longer be valid. |
| DPI | Kept, so prints come out at the correct physical size. |
| TIFF compression | LZW, Deflate, and PackBits compression are kept. |
| 16-bit grayscale | Kept as 16-bit in PNG and TIFF. Converted to 8-bit for JPEG, WEBP, and BMP, which can’t store it. |
| 16-bit color (48-bit) | Pillow reads these as 8 bits per channel, so the output is 8-bit. The script prints a warning when it detects this. |
| Transparency | Kept where the format supports it. For JPEG output, transparent areas are filled with the border color. |
If saving with full metadata fails (for example, an EXIF block the output format rejects), the script retries without it and prints a note.
--allow-overwrite.--dry-run writes nothing, not even the output folder.
--max-crop to cap how much can be removed per side, or lower --tolerance. Be aware that --max-crop also limits legitimate crops, so it works best on batches where the photos fill a similar share of each scan.--padding -2 removes this.--split-merge-distance.--workers if you run out of memory.
| Symptom | Try |
|---|---|
| “no border found” on scans that clearly have one | Check --color-hex, or use auto. Raise --tolerance. |
| One side isn’t cropped | There may be a dark line or artefact at that edge. Use --ignore-edge. |
| Too much is cropped | Lower --tolerance or --fill-threshold, or set --max-crop. |
| Too little is cropped | Raise --tolerance, lower --fill-threshold, or increase --denoise for dusty scans. |
| A photo is split into several pieces | Increase --split-merge-distance. |
| Dust is saved as a separate photo | Increase --split-min-area or --denoise. |
| “no consistent border color along the edges” | The photo probably touches the edge of the scan, so auto can’t find a border. Specify --color-hex. |


Leave a Reply