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:
- 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.
- 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:
- Change the preview card size on the fly. A user slides the “Preview” control from 300 px to 450 px to see more detail. The thumbnail re-renders at the new size; the stored display-space crop is now at the wrong scale, and the rectangle either jumps or has to be re-derived. With display-space storage, this is fragile.
- Apply to All across mixed resolutions. Suppose the first selected image is 4032×3024 (a phone photo at native res) and the third is 1280×720 (a screenshot). A display-space crop from the first image makes no sense applied to the third — it’d crop a region larger than the source.
- Export at full resolution. The on-screen thumbnail is downscaled (more on that below). A display-space crop measured at 800 px wide on a 4032 px wide source needs an exact 4032/800 multiply to recover the right pixels. Round-off errors at this step are visible in the output.
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:
- Drop the folder. All cards load, all are selected.
- Quickly deselect the cards that obviously need a different crop (different aspect, different subject placement, weird composition).
- Tune the crop on the first remaining selected card.
- Click Apply to All — that crop copies to the other selected cards. The deselected ones are untouched.
- Iterate on the deselected outliers individually.
- 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:
- Square (default). The crop rectangle is constrained to a square. The “Crop Size” field is the side length in pixels (default 1024, clamped 16-10000). When the user changes the value, every card’s crop is resized around its current centre to match.
- Free. Width and height are independent. Drag corners to resize asymmetrically.
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:
- The user’s mental model is “the dataset lives at this path.” When they go back to retrieve the ZIP, the natural place to look is in the dataset folder. Cluttering the parent with output files breaks that.
- The folder scanner filters by image extension (
.jpg/.jpeg/.png/.bmp/.webp/.tiff). A future re-open of the same folder won’t pick up the.zipas an image. There’s no accidental loop.
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:
- .NET 8 (Microsoft, MIT) — the runtime and the WPF framework.
- Microsoft.Web.WebView2 — Microsoft, distribution permitted. Used by the More Tools tab to embed the live ExpSoft products page.
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
- For a UI that overlays a region selection on a thumbnail, storing the region in source pixel space rather than display space unlocks a class of features (live resize, mixed-resolution batch operations, pixel-exact export from a downscaled preview). Two extra integers, big delta.
- For a gallery of hundreds of large images, downscale the thumbnail at load time but always crop from the source on export. Cheap RAM win, no loss of output quality.
- Make “select” and “Apply to All” share the same selection set. One concept, two affordances, cleaner workflow.
BitmapImage.CacheOption = OnLoad. Always.