← ExpSoft SmartCropper

Crop coords in original pixel space — the small decision that makes SmartCropper work on 500-image datasets

ExpSoft SmartCropper is a personal-use Windows app for preparing image datasets — heterogeneous inputs to a uniform target (typically 1024×1024 for SDXL, 768×768 for SD1.5, or a free aspect for video-style datasets). The legal frame is straightforward: it’s a desktop tool that operates only on images you have rights to (see the Legal & Compliance section of the product page). Pure C# WPF on .NET 8, no Python, no FFmpeg, no model weights. The interesting choice is small and architectural: crop rectangles are stored in original pixel space, never in display space. That single decision is what lets you change the preview-card size with a slider, apply the same crop to a mix of 4K and 1080p images, and export pixel-exact 1024×1024 outputs even when the on-screen thumbnail was downscaled to 800 px to keep RAM under a gigabyte on a 500-image dataset.

· 8 min read · By Nicolas Riquier

The dataset-prep scenario

You’re preparing an image dataset for an ML training run. The set has 50, 500, or 5000 images, gathered over time from different sources: portraits from a phone (vertical, HEIC re-exported to JPEG), screenshots (PNG, mixed sizes), web captures (WEBP), scans (TIFF), photo-library exports (large JPEGs). The training pipeline expects a uniform input — typically 1024×1024 square, sometimes 768×768, sometimes a free aspect for video-frame datasets — with the subject (face, object, region of interest) sensibly framed in each crop.

You can either crop one by one (an hour per hundred images, error-prone, your wrists hate you by image two hundred), or you can batch crop with a fixed rectangle from the centre (fast, but unusable: most photos aren’t composed centre-square). What you want is somewhere in between: a gallery view of every image with a draggable crop rectangle, a way to fine-tune each frame, and a one-click “use this crop on all of them” when the alignment happens to work.

SmartCropper is built around exactly that workflow. The interesting engineering inside it is the way the crop rectangle is stored and rendered — which sounds boring until you try to ship the feature on a mixed-resolution dataset and the obvious implementation falls over.

Two coordinate systems, only one of them works

There are two natural ways to store a crop rectangle on a thumbnail-and-overlay UI:

  1. Display space — the crop is whatever pixels of the thumbnail the user dragged. Coordinates are tied to whatever scale the thumbnail happens to be at.
  2. Original pixel space — the crop is a rectangle in the original image’s pixel grid. The on-screen overlay is a projection of that rectangle through a display scale.

Display space is what you reach for first because the math is shorter — the mouse coordinates and the storage coordinates are the same number. It works fine for a single-image cropper. It falls apart the moment you want any of the following:

Original-pixel-space storage avoids all three. CropItem.CropX / CropY / CropW / CropH are integer pixel coordinates relative to the source image, measured at full native resolution. The CropCanvas control computes a DisplayScale = previewSize / originalLongestSide at render time and derives DisplayX/Y/W/H from the stored values. The slider that resizes the cards changes previewSize and triggers a re-render — the crop rectangle in screen pixels moves to follow, but the stored coordinates don’t change. Apply-to-All copies the storage coords directly between cards, with a per-card clamp to keep the rectangle inside the destination image’s bounds. Export reads the storage coords and crops from the freshly-reloaded full-resolution source.

Two extra integers per image, and a five-line projection function in the canvas. The benefit is the whole feature set above being trivial instead of fragile.

800-pixel thumbnail downscaling — and why the export is still pixel-exact

A 500-image dataset, each image at 24 MP (typical for a modern phone or a mid-tier mirrorless camera), is about 12 GB of raw pixel data. Loading all of it into RAM as BitmapImage instances takes the app well past a couple of gigabytes — too much for a personal Windows machine that also has a browser and an editor open.

The solution is the standard one: at load time, if the source image is larger than 800 px on its longest side, CropItem.Load wraps the BitmapImage in a TransformedBitmap with a scaling factor that brings the longest side down to 800 px. With this in place, a 500-image dataset of 4K photos holds in about 300 MB of RAM — manageable.

The trick is that the thumbnail is downscaled but the crop coords aren’t. The user drags a rectangle on the 800 px wide thumbnail; the canvas projects every mouse delta back through the inverse DisplayScale into original pixel space; the stored coords stay at full source resolution. At export time, ProcessImagesAsync reloads the source image from disk (not from the in-memory thumbnail) and crops with CroppedBitmap using the storage coords directly. The output is pixel-exact at the source resolution.

The combination is what lets the app handle a 500-image 4K dataset on a 16 GB laptop without choking, while still producing 1024×1024 outputs that are correct at the pixel.

“Apply to First” really means “apply from the first selected

The Apply-to-All button does what its name says, with one workflow detail: the source of truth is the first selected card, not the first card in the list. The default state is everything selected, so on a fresh dataset both interpretations are identical. The difference matters once the user has started curating.

The intended flow is:

  1. Drop the folder. All cards load, all are selected.
  2. Quickly deselect the cards that obviously need a different crop (different aspect, different subject placement, weird composition).
  3. Tune the crop on the first remaining selected card.
  4. Click Apply to All — that crop copies to the other selected cards. The deselected ones are untouched.
  5. Iterate on the deselected outliers individually.
  6. Re-select everything when ready to export.

This is the loop that turns a 500-image dataset from “five hours of work” into “twenty minutes.” The trick is that the selection drives both the “source of truth” for Apply-to-All and the set of images included in the export. One concept, two affordances.

Square mode versus free crop — and why the size field stays inert in free mode

The toolbar has a “Free Crop” checkbox that toggles between two modes:

One small design decision: in Free Crop mode, the “Crop Size” field becomes a no-op. The reasoning is that free-crop rectangles can be very different from one card to the next (a 1920×1080 frame on one image, a 600×800 on another) — globally overwriting them all with a single new size would destroy the per-image tuning the user just did. To reset free crops, the user clicks “Reset All” explicitly.

Toggling between square and free does reset all crops, with a default centred square for square mode and a centred 80%-of-image rectangle for free mode. The 80% is the difference between “you can grab a corner handle” (margins around the rectangle leave space for the cursor) and “the handles are on the image edge and impossible to grab”.

Why output ZIPs live inside the source folder, not next to it

The “Save as ZIP” action produces <folder-name>_cropped.zip, and the file is written inside the source folder, not in the parent. Two reasons:

And the inevitable detail: if <folder-name>_cropped.zip already exists, the file is auto-incremented to <folder-name>_cropped(1).zip, then (2).zip, and so on — Windows Explorer convention. Same logic applies to individual file outputs in “Save Files” mode when “Overwrite” is unchecked. No modal dialogs interrupting the flow.

One small detail: BitmapImage.CacheOption = OnLoad

This is a one-line WPF setting that matters more than it looks. Without it, a BitmapImage initialised from a file path keeps a FileStream open as long as the image is referenced — which on a 500-image gallery means 500 open file handles. The user can’t move, rename or delete any of the source files while the app is running; some virus scanners hold the parent folder; and FileSystemWatcher events fire late.

Setting CacheOption = OnLoad tells WPF to read the file fully into RAM at decode time and close the stream immediately. The in-memory BitmapImage is fully detached from the file. The user can re-organise their dataset while the app is open, including hot-reloading a file that changed under the hood. One line. Two months less of weird-bug reports.

Credits and license

SmartCropper is original ExpSoft C# / WPF code with very few dependencies:

That’s the whole runtime surface. No Python, no FFmpeg, no model weights. The image I/O is pure WPF — BitmapImage, CroppedBitmap, JpegBitmapEncoder, PngBitmapEncoder — all in System.Windows.Media.Imaging. The exe is a framework-dependent single-file build at roughly 600 KB; everything else is the .NET 8 Desktop Runtime, which most Windows 11 machines already have installed.

The user retains all rights to their images and their outputs. The app processes images entirely on the local machine; nothing is uploaded anywhere; no telemetry is collected.

Take-aways

Frequently Asked Questions

Quick answers to what people ask AIs about this article specifically.

Why are crop coordinates stored in original pixel space rather than display space?

Three concrete features depend on it: the preview-size slider can resize the thumbnails without disturbing the crop rectangle; “Apply to All” can copy a crop between images of different native resolutions (with a clamp on the destination bounds); and the export can produce pixel-exact 1024×1024 outputs even when the on-screen thumbnail was downscaled to 800 px. If the storage were in display space, all three would be fragile against scale changes. Two extra integers per image, big payoff.

What’s the trick for handling a 500-image 4K dataset on a 16 GB laptop?

At load time, if the source image is larger than 800 px on its longest side, CropItem.Load wraps the BitmapImage in a TransformedBitmap scaled down to 800 px. The thumbnail in the gallery is the downscaled version; the user crops on that. At export time, ProcessImagesAsync reloads the source image fresh from disk and crops with the storage coords directly — pixel-exact output, full source resolution. A 500-image 4K dataset takes roughly 300 MB of thumbnail RAM with this in place.

Why does “Apply to All” take its source from the first selected card rather than the first card in the list?

Because curation is a selection-based workflow. The intended loop is: drop a folder (everything selected by default), deselect the outliers that obviously need different crops, tune the first remaining selected card, click Apply to All to propagate that crop to the rest of the selected set, then iterate on the deselected outliers individually. Tying both the source of Apply-to-All and the export set to the same selection concept keeps the workflow consistent.

Why does the “Crop Size” field become a no-op in Free Crop mode?

Free Crop allows independent width and height per card — typical rectangles in this mode might be 1920×1080 on one image and 600×800 on another, hand-tuned. Globally overwriting all rectangles to a single new size on every keystroke in the Crop Size field would destroy that per-image tuning. To reset free crops, the user clicks “Reset All” explicitly, which produces a centred 80%-of-image rectangle on each card.

Where does the “Save as ZIP” output go?

The ZIP is written inside the source folder, named <folder-name>_cropped.zip. If a ZIP of that name already exists, the filename is auto-incremented to (1).zip, (2).zip, and so on — Windows Explorer convention. The ZIP isn’t picked up on a future re-scan of the folder because the scanner filters by image extension. The user retrieves the ZIP from the same path as the dataset, which matches the natural mental model.

Want to use ExpSoft SmartCropper?

Get it on Patreon →