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.
A desktop tool for comparing two images, or two folders of images, pixel for pixel. You can view a pair with a draggable split divider, stack the two images and flip between them with a click, or show their pixel difference. It also has zoom, pan and a magnifying loupe for close inspection.
Built with Python, Tkinter and Pillow.
tkinterdnd2).rawpy). See Camera RAW files.
sudo apt install python3-tk.With no arguments, the app reopens the last paths you used. You can also pass paths on the command line:
# Compare two folders
python image-compare.py --left-file-or-folder-path ./before --right-file-or-folder-path ./after
# Compare two specific files (their names don't need to match)
python image-compare.py --left-file-or-folder-path a.png --right-file-or-folder-path b.png
# Match images in sub-folders too
python image-compare.py --left-file-or-folder-path ./before --right-file-or-folder-path ./after --recursive TRUE
# Check the image functions and the installed features, without opening a window
python image-compare.py --selftest TRUE
| Option | Description |
|---|---|
--left-file-or-folder-path PATH |
File or folder for the left side. |
--right-file-or-folder-path PATH |
File or folder for the right side. |
--recursive TRUE |
Include images in sub-folders, matched by relative path; FALSE leaves them out. Blank: the choice saved last time. |
--working-folder-path FOLDER |
The folder for the settings file (image-compare.ini) and temporary files, created if needed. The default is %APPDATA%\image-compare on Windows (see Settings). A folder that can’t be created is an error (exit status 2). |
--selftest TRUE |
Check the image functions and report the optional features (RAW support, drag and drop, Tkinter), then exit. See Testing. |
Paths given on the command line take priority over the saved ones.
Every option is named and takes a value, written --name VALUE or --name=VALUE, in any order.
Yes/no options take TRUE or FALSE (also 1/0, YES/NO, ON/OFF, in any case); script launchers that read the options (such as Scriptary) offer them as buttons.python image-compare.py --help lists the options with their defaults. A mistake on the command line (an unknown or shortened option, an option given twice or without its value, or a path without an option) stops the app with a message and exit status 2; started without a console, as with pythonw.exe, the message is shown in a message box instead.
sub/dir/photo.png on the left pairs with sub/dir/photo.png on the right). Pairs are sorted by path, case-insensitively.Supported formats are .jpg, .jpeg, .png, .bmp, .gif, .tiff, .tif and .webp, and, with rawpy installed, camera RAW files (see Camera RAW files). Images are converted to 8-bit sRGB for display (see Color profiles). 16-bit grayscale files are scaled from 0–65535 down to 0–255; a 16-bit file whose values all fit in 0–255 is shown as it is.
Photos are turned upright from their EXIF orientation tag, as cameras and phones set it, before they are shown or compared. Resolutions, and the size check below, are for the photo turned upright, so a portrait photo stored sideways with a rotation tag pairs cleanly with an upright copy of it.
From top to bottom, the window contains:
The two path fields share what’s left of the row’s width.
On every switch, clicking the option that is already lit does nothing.
3 / 20), and the right file, each with its native resolution and how its colors were read (see Color profiles). A bold ⚠ note appears here when the two sizes differ.same an identical pair (its name dimmed), and a percentage the share of pixels that differ; these are worked out in the background.shot.CR2 with no shot.CR2 on the right shows there even when there is a shot.jpg); they can’t be opened, having nothing to be compared with. The line below the list shows how many pairs are mismatched and identical, how many images are on one side only, and scanning… while the background checks are still going. Drag the line between the panel and the Compare area to make the panel wider or narrower (from 160 to 700 pixels at 100% display scaling, always leaving the Compare area at least 400); it keeps that width when the window is resized, and next time.Opening… meanwhile.
| Action | Slider mode | Overlay mode |
|---|---|---|
| Left-click / drag on image | Move the divider | Flip between left and right |
| Mouse wheel | Zoom around the cursor | Zoom around the cursor |
| Middle-drag, right-drag or Shift + left-drag | Pan | Pan |
| Click a row in the Pairs panel | Open that pair (← and → then work at once, even after typing a path) | Open that pair (likewise) |
| Drag the line beside the Pairs panel | Widen or narrow the panel | Widen or narrow the panel |
| Key | Action |
|---|---|
← / → |
Previous / next pair (stops at the first and last) |
Shift+← / Shift+→ |
Previous / next pair that isn’t identical (pairs not yet measured count as changed; stops at the first and last) |
+ or = |
Zoom in |
− |
Zoom out |
0 |
Fit to window |
1 |
100% (one screen pixel per image pixel) |
[ / ] |
Nudge the divider left / right by 1% (Slider mode) |
Space |
Flip between left and right (Overlay mode) |
Shortcuts are ignored while you’re typing in a path field or the × box, and while a dialog is open.
When tkinterdnd2 is installed:
The pair list rescans automatically once both sides have a path.
Slider (the default) splits the view at the divider, with the left image on the left and the right image on the right. Each side’s folder or file name is shown in a caption in its top corner, with the side’s square.
Overlay shows one full image at a time, in the same position and at the same zoom. Click anywhere on the image, or press Space, to flip to the other one. Only the caption for the image currently showing is displayed, in that side’s usual corner, and the Compare readout says showing LEFT or showing RIGHT. Switching back and forth quickly is often the fastest way to catch small shifts, crops or color changes.
Difference takes precedence over both. When it is on, the view shows |left − right| for each pixel: black where the images are identical, brighter where they differ. Raise the × factor to multiply the difference and make subtle changes visible.
Difference needs both images on the same pixel grid, so it turns Match sizes on automatically if the resolutions differ.
The slider/overlay choice is remembered between sessions. Difference and Loupe always start off.
With Loupe on, a 360 × 360 pixel magnified inset (at 100% display scaling) follows the cursor. It appears just beside the outlined area it is sampling, so it never covers the spot you’re looking at. It moves to the other side of the cursor, or above/below it, near the edges of the view.
What the loupe shows depends on the mode:
A caption strip along the bottom of the loupe shows the magnification and the pixel coordinates of the cursor in the source image.
To change the loupe, edit these constants near the top of the script:
LOUPE_SIZE = 360 # on-screen size, in pixels at 100% display scaling
LOUPE_GAP = 16 # gap between the sampled area and the loupe, likewise
LOUPE_ZOOM = 4.0 # magnification relative to the current view
Every pair is measured on its whole images, as they are shown: in sRGB, upright, at full size. The Compare area’s header says how the open pair differs:
Identical: no pixel differs when every pixel of the two images is the same.1.0% of pixels differ · largest difference 190 · average 0.42: the share of pixels where any of red, green or blue differs, the largest difference in any channel (out of 255), and the average difference per channel over the whole image.A pair whose sizes differ isn’t measured, since resizing one image to the other’s size changes every pixel; the ⚠ says the sizes differ instead.
The Pairs panel shows the same share for each row, and same for identical pairs. The open pair is measured once it is on screen. The others are measured one at a time in the background, starting from the selected pair, and only while no pair is being opened, so they never slow down the pair you are looking at. Measuring needs each image decoded: for a pair of 6000 × 4000 images, measuring took 0.5 seconds on top of opening them, and a folder of large or RAW pairs takes a while to measure in full. Rescan measures everything again, in case files changed.
When the two images in a pair have different resolutions, the app flags it in the info line and marks the row with ⚠ in the Pairs panel. A mismatch usually means you’re comparing the wrong things, so the app points it out instead of silently hiding it.
Each image is converted to sRGB from the ICC profile embedded in it, so the views, the loupe, the thumbnails and Difference all compare colors, not raw values in different color spaces. An image saved in Adobe RGB and the same image saved in sRGB look the same and show a black Difference.
The info line says how each side’s colors were read, in small text next to its resolution. A long note wraps onto a second line rather than being cut off.
| Note | Meaning |
|---|---|
No embedded profile, assumed sRGB |
The file has no embedded profile. Its values are shown as sRGB, unchanged. |
Embedded profile: and its name, such as sRGB IEC61966-2.1 |
The file is already sRGB, so it is shown unchanged. |
Converted from the profile’s name to sRGB |
The file’s colors were converted from its profile to sRGB. |
Embedded profile could not be read, assumed sRGB |
The embedded profile couldn’t be read, so the values are shown unchanged. |
CMYK without a profile, converted naively; readings are approximate |
A CMYK file with no profile, converted with a simple formula. |
RAW decoded with camera white balance and a default rendering, not your darktable edit |
A camera RAW file, decoded straight to sRGB (see Camera RAW files). |
Conversion uses the relative colorimetric intent. Colors outside sRGB, which Adobe RGB, Display P3 and ProPhoto can hold, are clipped to the nearest sRGB color, so two images that differ only in those colors can show as identical.
In testing, images converted from sRGB into Adobe RGB, Display P3 and ProPhoto and saved as PNG came back within 1 level of the original (0.2 on average); the same images saved as JPEG at quality 97 were within 0.6 on average, the rest being compression. Converting takes time: a 6000 × 4000 JPEG with an Adobe RGB profile opened in 0.88 seconds, against 0.16 seconds without a profile. A 6000 × 4000 16-bit grayscale PNG opened in 0.32 seconds, against 0.13 seconds for an 8-bit one. Thumbnails are made from the images already opened, so they add almost nothing.
With rawpy installed, camera RAW files (.cr2, .cr3, .crw, .nef, .nrw, .arw, .srf, .sr2, .dng, .raf, .orf, .rw2, .pef, .srw, .3fr, .iiq, .x3f, .erf, .mrw, .kdc) are decoded with the camera’s white balance and LibRaw’s default rendering, straight to 8-bit sRGB, turned upright, at full size. This is not your darktable edit: .xmp sidecar files are ignored. To compare edits, export them from darktable and compare the exports.
Pairs are matched by the whole file name, extension included, so shot.CR2 pairs with shot.CR2, not with shot.jpg. To compare a RAW file with an export of it, choose the two files with File….
The size check behind the ⚠ marks reads a RAW file’s size with rawpy, not from the preview image many RAW files carry: for a Canon 5D Mark II CR2, the preview is 5616 × 3744 but the decoded image is 5634 × 3752.
Decoding is slow. That 21-megapixel CR2 took 1.9 seconds to decode, and a pair of two of them 4.6 seconds to open. The window keeps responding meanwhile, and the info line says opening RAW….
The next pair is opened ahead while you look at one, so moving through a folder in order waits only when you move faster than that. Its thumbnails in the Pairs panel are made from the decoded images, in 0.07 seconds.
Without rawpy, RAW files in folders aren’t listed or paired, and the File… dialog lists them only under All files. Two RAW files chosen with File… still pair: opening them clears the compare area and says what to install (pip install rawpy), or to export the photo as an sRGB JPEG, PNG or TIFF instead, and the info line shows ⚠ can't open the left image (or right). The same happens for any file that can’t be opened.
At 200% and above, images are scaled with nearest-neighbor sampling so individual pixels stay crisp. Below that, Lanczos resampling is used to avoid aliasing. Only the visible region is resized, so zooming into very large images stays responsive.
While the view is changing (zooming with the wheel or the buttons, panning, resizing the window, or a new pair opening), a quick preview is drawn at once with a faster filter, and the Lanczos version replaces it once the view has stayed the same for 0.4 seconds. The preview differs from the final view by about 3 levels out of 255 on average. For a pair of 6000 × 4000 images at Fit in a 1357 × 671 view, the preview took about 160 ms and the full version about 460 ms in testing. At 100% and from 200% the two are the same, so there is nothing to wait for.
Dragging the divider and flipping in Overlay reuse the views already drawn, so they are immediate.
The view resets to Fit whenever you open a new pair.
Zoom ranges from 2% to 6400%.
On Windows, the app declares itself DPI-aware, so 100% really means one screen pixel per image pixel, even when display scaling is set to 125% or 150%.
The interface follows the display scaling set in Windows or Linux.
Sizes are designed for a 100% (96 dpi) display and scaled to match: at 150%, the text, buttons, spacing, the rows of the Pairs panel, the thumbnails, the divider handle and the loupe are all half as large again. The images themselves are not scaled this way: 100% zoom stays one screen pixel per image pixel.
The toolbar fits on one row from about 1250 pixels at 100% with Linux’s default font (about 1800 at 150%), less than the minimum width, so it stays on one row there; with a larger font, Difference and Loupe move to a second row when it doesn’t fit. The window can’t be made narrower than 1400 pixels at 100% (2100 at 150%), which leaves the info line room for long file names on both sides. The window opens at 1560 × 820 at 100% the first time, scaled likewise.
Settings are saved when you close the window, as image-compare.ini in the app’s working folder: %APPDATA%\image-compare on Windows, ~/Library/Application Support/image-compare on macOS, and ~/.config/image-compare (or $XDG_CONFIG_HOME/image-compare) elsewhere.python image-compare.py --help shows the folder on your computer. Use --working-folder-path to put it somewhere else. If the default folder can’t be created, the app still runs, without saving.
The saved settings are:
[paths]: the left and right paths, and the last folder each side used (left_folder, right_folder)[compare]: Recursive, Match sizes, the Difference factor (×1–32) and the comparison mode (Slider or Overlay)[window]: the window’s size, position and maximized state[panels]: the width of the Pairs panel, where its divider was leftThe file is plain text and can be edited by hand. A value that can’t be read, or is out of range, falls back to its default, so a damaged file never stops the app from starting.
Older versions saved image_compare_settings.json beside the script. The first time this version starts without an image-compare.ini, it brings those settings over, including the older path_left / path_right and folder_left / folder_right keys. The old file is left as it was.
image-compare.ico, next to the script (or the frozen executable), is the icon of the title bar and the taskbar. On Windows the .ico file is used as it is, so each place takes the size it needs; elsewhere every size in it (16 to 256 pixels) is handed to Tk. If the file is missing, the app runs with Tk’s own icon.
On Windows the app has its own taskbar ID, infolific.image-compare, so its button groups and pins as Image Compare rather than as Python. Versions before this one used a different ID: if you pinned the app to the taskbar, pin it again.
image-compare-generate-icons.py draws the icon file, the PNGs in resources/ and the light and dark previews in the root folder with Pillow.
python image-compare.py --selftest TRUE checks the image functions without opening a window, printing one line per check and All checks passed. at the end. It exits with status 0 when every check passes and 1 otherwise. It checks:
It then says which optional features are installed. It takes about 0.2 seconds, works in a temporary folder, and doesn’t touch your settings. It runs without Tkinter too, so it can tell you Tkinter is missing.
python -m pytest runs the GUI tests: they open the real app on images they generate, work its controls with real mouse and key events, and check what it shows on screen. They need pytest (pip install pytest) as well as Pillow and Tkinter. Without a working $DISPLAY they start Xvfb (and openbox, if installed) themselves; with no display at all they are skipped.python -m pytest -k zoom runs the tests whose names contain zoom. The 35 tests cover:
[ ] keys;same or with its share, and Shift+→ skipping identical pairs;Opening… in place of the old images, and the next pair preloaded and shown at once;$RAW_TEST_FILE (skipped otherwise);--working-folder-path creating the folder and keeping the settings there, the paths and --recursive TRUE given in any order, --recursive FALSE overriding a saved choice, a working folder that can’t be created, an unknown option and a bare switch each exiting with status 2, --selftest TRUE, and --help; the yes/no options’ TRUE and FALSE written out where Scriptary reads them;They take about 33 seconds, or about 41 with the RAW test, and pass at 100% and 150% display scaling. To test another copy of the script, set APP_SCRIPT=/path/to/copy.py. Each test uses its own temporary folders and working folder.
Before each change:
python image-compare.py --selftest TRUE and python -m pytest.python3 -c "import ast, glob; [ast.parse(open(f, encoding='utf-8').read(), feature_version=(3, 8)) for f in glob.glob('*.py') + glob.glob('*/*.py')]"python3 -m pyflakes on the changed files, and python templates/tools/check_copies.py --app-folder-path . to check that the code copied from the templates still matches them.-dpi 144 to check 150% display scaling.
sudo apt install python3-tk.python image-compare.py --selftest TRUE says whether it’s there.--working-folder-path.--working-folder-path.image-compare.ini in the working folder instead of image_compare_settings.json beside the script. It brings the old file’s settings over the first time it starts, as long as the old file is still beside the script then.infolific.image-compare). Unpin the old button and pin the app again. The icon itself is image-compare.ico beside the script (see Window icon).[window] section of image-compare.ini.pip install rawpy, or export the photo from darktable as an sRGB JPEG, PNG or TIFF and compare that.pip install tkinterdnd2. The status line says whether it’s available; the Folder… and File… buttons work either way.


Leave a Reply