A .NET command-line tool that batch-normalizes icon images into flat single-colour coverage masks by recoloring, trimming, squaring, and resizing them into consistent PNGs.
ktsu.IconHelper is a small console application for preparing icon sets. Icon packs downloaded from
different sources rarely agree on colour, padding, or canvas size, which makes them look inconsistent
when placed side by side in a UI. IconHelper takes a directory of images, converts each one to a
flat coverage mask in a colour of your choosing, trims away the transparent margins, centres the
artwork on a square canvas, and writes out a uniformly sized PNG.
"Coverage mask" is the important part: every pixel of the output carries the same colour, and the whole shape, anti-aliased edges included, lives in the alpha channel. Nothing in the result is a darker shade of the tint, so the icons composite cleanly over a background of any colour.
It is built on SixLabors.ImageSharp, so it runs anywhere .NET does and needs no native image libraries or platform-specific dependencies.
- Batch Processing: Processes every file in an input directory in a single run
- Coverage Output: Flattens each image to a mask painted in one flat colour, with the shape carried entirely by the alpha channel
- Automatic Trimming: Detects the bounding box of the pixels that end up visible and crops to it
- Square Centring: Pads the trimmed artwork to a square canvas so icons align consistently
- Configurable Padding: Insets the artwork by a fixed number of pixels per side without changing the output dimensions
- Downscale-Only Resizing: Shrinks artwork to a maximum size but never upscales, so nothing is blurred
- Alpha Coverage: Writes 8-bit RGBA PNGs whose alpha is the source transparency multiplied by the source brightness
- Resilient: Reports and skips any file it cannot process, so one bad input never aborts the batch
IconHelper is distributed as a .NET tool. Install it globally from NuGet:
dotnet tool install --global ktsu.IconHelperThat puts an iconhelper command on your PATH. To update or remove it later:
dotnet tool update --global ktsu.IconHelper
dotnet tool uninstall --global ktsu.IconHelperTo pin the tool to a single repository instead of installing it machine-wide, use a local tool manifest:
dotnet new tool-manifest
dotnet tool install ktsu.IconHelper
dotnet tool run iconhelper --input ./icons --output ./outThe package is framework-dependent and runtime-agnostic, so it needs the .NET 10 runtime installed. There is no standalone, self-contained binary.
git clone https://github.com/ktsu-dev/IconHelper.git
cd IconHelper
dotnet build -c Releasedotnet run --project IconHelper/IconHelper.csproj -- --input ./icons --output ./outRecolour every image in ./icons to white and write the results to ./out:
iconhelper --input ./icons --output ./outColours are parsed with ktsu.Semantics.Color. Hex values in
#RGB, #RRGGBB and #RRGGBBAA form are accepted, with the leading # optional, along with a small
set of colour names:
# Six digit hex
iconhelper -i ./icons -o ./out -c "#FF8800"
# Three digit shorthand, equivalent to #FF8800
iconhelper -i ./icons -o ./out -c "#F80"
# Eight digit hex. The alpha component is accepted but ignored, because the
# alpha channel of the output is the coverage, not the colour's own opacity.
iconhelper -i ./icons -o ./out -c "#FF8800AA"
# Named colour
iconhelper -i ./icons -o ./out -c "orange"The known names are black, white, red, green, blue, yellow, cyan, magenta, gray,
grey, orange, purple and transparent, matched case insensitively. Any other colour must be
given as hex. An unrecognised value is rejected by validation with the list of names, rather than
being silently misread.
Icons larger than 64x64 are scaled down to fit. Smaller icons are left at their natural size:
iconhelper -i ./icons -o ./out -s 64Inset the artwork by 8 pixels on each side. The output canvas stays the same size, and the artwork inside it is scaled down to make room:
iconhelper -i ./icons -o ./out -s 128 -p 8iconhelper --input ./raw-icons --output ./themed-icons --color "#E0E0E0" --size 96 --padding 6Output while running:
Processing ./raw-icons/save.png...
Processing ./raw-icons/open.png...
Processing ./raw-icons/notes.txt...
Failed to process ./raw-icons/notes.txt: UnknownImageFormatException: Image cannot be loaded...
Done. 2 file(s) written, 1 failed.
The whole design follows from one goal: reduce artwork of unknown origin to a single-colour coverage mask without destroying the anti-aliased edges that make an icon look smooth at small sizes. A naive approach, thresholding to pure black and white and painting the result, produces jagged icons. Each stage below exists to avoid that.
The image is loaded as RGBA32 and put through ImageSharp's BlackWhite filter. That filter is a
colour matrix whose red, green and blue rows are all 1.5, with a -1 offset row and the alpha row
left at 1. In normalized 0 to 1 terms every output channel becomes the same value:
out = clamp01(1.5 * (R + G + B) - 1)
Because all three outputs are identical the image collapses to one tonal channel, which is why every later step reads the red channel alone and treats it as intensity.
For a pixel that is already grey with value v this reduces to 4.5v - 1, a steep ramp that clamps
to black at v <= 2/9 and to white at v >= 4/9. The result is deliberately near binary rather
than binary: most pixels land on pure black or pure white, and only a narrow band along
anti-aliased edges keeps genuine midtones. Those midtones are the anti-aliasing, and preserving
them is the reason for everything that follows.
Alpha passes through untouched.
A first pass records the highest tonal value across pixels whose alpha is not zero.
Transparent pixels are excluded deliberately. The colour matrix has no alpha awareness, so it rewrites the colour channels of fully transparent pixels too, and many encoders leave arbitrary values in the RGB of a transparent pixel to begin with. Including them would skew the maximum against artwork that is mostly empty canvas, which most icons are.
This has to be a separate pass, because the tint in stage 3 cannot start until the maximum for the whole image is known.
A second pass normalizes each pixel to full intensity, folds that intensity into the alpha channel, and paints the colour channels flat:
intensity = 255 - (maxValue - red) // opaque pixels
intensity = 0 // transparent pixels
alpha = sourceAlpha * intensity / 255
channel = targetChannel // every pixel, unmodulated
This is what makes the output a coverage mask. Brightness and transparency are two ways of saying the same thing here, so they are merged into one: a half lit pixel comes out as the target colour at half alpha rather than as a half dark version of that colour. The colour channels carry no shape information at all.
The practical difference is what the result composites over. A darkened edge pixel is only correct against the black it was implicitly matted against; the same pixel expressed as partial coverage is correct against any background.
The normalization is an offset rather than a scale, and that choice matters. Adding
255 - maxValue to every pixel raises the brightest opaque pixel to exactly 255 while preserving
the absolute differences between neighbouring tones. Scaling instead would stretch those differences
apart and visibly harden the anti-aliased edge. Artwork whose brightest pixel is already 255, which
is most of it after stage 1, passes through unchanged.
Two details are load bearing:
- All-black artwork is special-cased. If the brightest opaque pixel is still 0, the glyph is a solid black silhouette carrying its shape entirely in the alpha channel. Those pixels are forced to full intensity, because normalizing them would resolve to intensity 0, which now collapses the alpha to 0 and the icon would come out fully transparent.
- The colour is painted flat everywhere, transparent pixels included. Whatever RGB the decoder left behind, and equally a zeroed one, gives the resize in stage 5 a different colour to blend inward at the edges, which is what produces a dark or off-colour halo. A uniform colour field cannot: every weighted average of a single colour is that colour.
The same pass accumulates the bounding box of the pixels that ended up with non-zero coverage, since it is already visiting every pixel. The image is cropped to that box, which discards whatever empty margin the source had, then padded with transparency on the shorter axis to make it square. Padding rather than stretching keeps the artwork's aspect ratio intact and centres it.
The bounds are measured against the merged alpha rather than the source alpha. An opaque but unlit region contributes nothing visible once brightness has become coverage, so including it would pad the canvas out around artwork that is no longer there.
The bounds are inclusive indices, so the width is right - left + 1. Dropping that + 1 costs the
rightmost column and bottom row of every icon.
The final side is min(squareSize, --size). The minimum is what makes this downscale only:
enlarging a small icon would just interpolate detail that was never there, so a --size larger than
the artwork leaves it alone.
--padding insets the content without changing the canvas. The artwork is resized to
finalSize - padding * 2 and then padded back out to finalSize, so the output is always
finalSize square whatever the padding.
The result is written as an 8-bit RGBA PNG, with the encoder set to clear the colour channels of fully transparent pixels so no invisible colour data is carried into the file.
An image with no visible pixels has no bounding box to measure, so stages 4 and 5 are skipped and a fully transparent square is written instead, sized by the same downscale-only rule applied to the source canvas.
Files whose names contain .new.png are skipped, so re-running the tool over a directory that
already contains its own output will not reprocess those files.
| Short | Long | Required | Default | Description |
|---|---|---|---|---|
-i |
--input |
Yes | n/a | Path to the directory containing the input files |
-o |
--output |
Yes | n/a | Path to the directory where modified files are written |
-c |
--color |
No | #FFFFFF |
The colour to tint the icon with, as hex or a known name |
-s |
--size |
No | 128 |
The maximum size, in pixels, of the output icon |
-p |
--padding |
No | 0 |
Pixels of padding per side. Must be less than size / 2. Does not change the output dimensions |
| Code | Meaning |
|---|---|
0 |
Every file was processed successfully. Also returned for --help and --version |
1 |
The arguments were unusable, for example an --input directory that does not exist, an unrecognised --color, or padding >= size / 2 |
2 |
The batch ran to completion but at least one file could not be processed |
Code 2 means the run finished and the remaining icons were still written. Check the summary line
for how many succeeded.
- Output is always PNG, and the extension is rewritten to match, so
logo.jpgbecomeslogo.png. If the input directory holds two files with the same base name but different extensions, the later one overwrites the earlier. - Input formats are whatever ImageSharp can decode (PNG, JPEG, BMP, GIF, TGA, TIFF, WebP, PBM, QOI). Vector formats such as SVG are not supported.
- The tool only ever shrinks artwork. Passing a
--sizelarger than the source icon leaves it at its original size. - Colour information in the source is discarded, so every icon becomes a single flat colour.
- Source brightness becomes transparency rather than a darker colour. A region that flattens to black is fully transparent in the output and falls outside the crop, instead of appearing as an opaque black patch. Artwork whose brightest pixel is dim therefore produces a mask that is translucent throughout, since the normalization has only that pixel to scale against.
- Every failure is reported and skipped, so the run always continues to the end and exits with
code
2if anything failed.
Contributions are welcome! Feel free to open issues or submit pull requests.
This project is licensed under the MIT License. See the LICENSE.md file for details.