Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dotnet tool install ktsu.IconHelper --add-source ./pkg --tool-path ./toolpath
## Project Structure

This is a .NET **console application** (`IconHelper`), not a library. It batch-processes icon
images: recolouring them to a single-colour silhouette, trimming transparent margins, squaring the
images: reducing them to a single-colour coverage mask, trimming transparent margins, squaring the
canvas, and resizing to a maximum dimension.

It is distributed as a **dotnet tool**: package `ktsu.IconHelper`, command `iconhelper`. See
Expand Down Expand Up @@ -77,18 +77,26 @@ The program is a single-pass batch processor with no abstraction layers, which i

```
Parse args → Validate → enumerate input dir → per file:
Load<Rgba32> → BlackWhite() → find max opaque luminance → tint by colour
→ crop to alpha bounding box → pad to square → resize → pad to final size → SaveAsPng
Load<Rgba32> → BlackWhite() → find max opaque luminance → fold brightness into alpha and
paint the colour flat → crop to coverage bounding box → pad to square → resize
→ pad to final size → SaveAsPng
```

The recolouring algorithm is documented step-by-step in inline comments in `IconHelper.cs`. Read
those before changing the pixel maths. Two details in particular:
The coverage algorithm is documented step-by-step in inline comments in `IconHelper.cs`. Read
those before changing the pixel maths. Three details in particular:

- **The output is a coverage mask, not a tinted silhouette.** Every pixel's RGB is the target colour
flat, transparent pixels included, and the normalized brightness is multiplied into the alpha
instead (`alpha = sourceAlpha * intensity / 255`). Source brightness therefore becomes
transparency, not a darker colour. A region that flattens to black comes out fully transparent and
is excluded from the bounding box rather than cropped around. Painting the colour into transparent
pixels too is deliberate: a uniform colour field gives `Resize` nothing to blend inward at the
edges, which is what used to produce the halo that zeroing them was guarding against.
- **Two `ProcessPixelRows` passes.** The first finds the brightest opaque pixel (`maxValue`). The
second applies the tint *and* accumulates the alpha bounding box. They cannot be merged, because
the tint depends on `maxValue` being known up front.
second applies the coverage *and* accumulates the bounding box. They cannot be merged, because the
normalization depends on `maxValue` being known up front.
- **The all-black special case.** If `maxValue == 0` every opaque pixel is treated as full intensity.
Without this, solid black glyphs would tint to black and appear blank.
Without this, solid black glyphs would resolve to zero coverage and come out fully transparent.

Sizing is deliberately downscale-only: `finalSize = Math.Min(trimmedSquareSize, args.Size)`. Padding
is applied by shrinking the *content* (`finalSize - padding * 2`) and padding back out, so the output
Expand Down Expand Up @@ -117,7 +125,8 @@ Paths and colours are semantic types rather than strings.
- Semantic strings define an implicit conversion to `string`, so pass them straight to BCL APIs
rather than calling `ToString()`.
- `ColorParser.TryParse` accepts a `NamedColors` name or a hex value. `Color` stores **linear**
channels as doubles, so `ProcessImage` calls `ToBytes()` once up front rather than per pixel.
channels as doubles, so `ProcessImage` calls `ToBytes()` once up front rather than per pixel. The
alpha component of an `#RRGGBBAA` colour is discarded, since alpha is what carries the coverage.
`FromHex(...).ToBytes()` round-trips byte for byte, which is why swapping the parser left every
gold master unchanged.

Expand Down Expand Up @@ -172,8 +181,9 @@ regression.

- `ArgumentsTests` - option defaults and the padding-versus-size validation rule
- `ProcessImageTests` - the pixel pipeline in isolation: squaring, downscale-only clamping, trimming,
tinting, the all-black branch, midtone normalization, colour flattening, padding and the blank
image case
colouring, the all-black branch, midtone normalization, the brightness-into-alpha merge, source
alpha multiplying through, unlit regions dropping out of the crop, colour flattening, padding and
the blank image case
- `ProcessDirectoryTests` - the I/O layer: output directory creation, `.png` extension rewriting,
`.new.png` skipping, per-file error recovery for both decode failures and locked files, the
written and failed counts, and the PNG encoder settings
Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
A .NET command-line tool for batch-normalizing icon images into a consistent set. Recolours each image to a single-colour silhouette, trims transparent margins, centres the artwork on a square canvas, and resizes it to a maximum dimension with optional padding. Built on ImageSharp with no native dependencies, making it a fast way to unify icon packs collected from different sources.
A .NET command-line tool for batch-normalizing icon images into a consistent set. Reduces each image to a flat single-colour coverage mask that carries its shape, anti-aliased edges included, entirely in the alpha channel, trims transparent margins, centres the artwork on a square canvas, and resizes it to a maximum dimension with optional padding. Built on ImageSharp with no native dependencies, making it a fast way to unify icon packs collected from different sources.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
102 changes: 95 additions & 7 deletions IconHelper.Test/ProcessImageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,107 @@ public void PaintsAllBlackArtworkInTheTargetColour()
}

[TestMethod]
public void NormalizesMidtoneArtworkUpToFullIntensity()
public void NormalizesMidtoneArtworkUpToFullCoverage()
{
// A mid-grey of 80 lands inside the BlackWhite ramp, so maxValue ends up strictly
// A mid-grey of 80 lands inside the BlackWhite ramp at 105, so maxValue ends up strictly
// between 0 and 255 and the offset normalization has to lift it back to full intensity.
// Since intensity is now what drives alpha, "full intensity" means "fully covered".
using Image<Rgba32> image = TestImages.Blank(80, 80);
TestImages.FillRect(image, 20, 20, 40, 40, new Rgba32(80, 80, 80, 255));

IconHelper.ProcessImage(image, Color.FromBytes(200, 100, 50), 40, 0);

Rgba32 brightest = TestImages.BrightestOpaquePixel(image);
Assert.AreEqual(200, brightest.R, "The brightest opaque pixel should reach the target colour exactly.");
Assert.AreEqual(100, brightest.G);
Assert.AreEqual(50, brightest.B);
Rgba32 centre = image[20, 20];
Assert.AreEqual(255, centre.A, "The brightest opaque pixel should reach full coverage.");
Assert.AreEqual(200, centre.R, "The colour channels carry the target colour flat.");
Assert.AreEqual(100, centre.G);
Assert.AreEqual(50, centre.B);
}

[TestMethod]
public void FoldsBrightnessIntoTheAlphaChannel()
{
// The core of the coverage output. A white patch and a mid-grey patch are equally opaque in
// the source, so under the old tint they differed only in how dark the colour came out.
// Now they differ in alpha instead, and the colour is identical across both.
//
// The grey of 80 passes through the BlackWhite ramp to 105, and the white patch pins
// maxValue at 255, so the grey normalizes to 105 and 255 * 105 / 255 is 105 of coverage.
using Image<Rgba32> image = TestImages.Blank(80, 80);
TestImages.FillRect(image, 10, 10, 20, 20, OpaqueWhite);
TestImages.FillRect(image, 40, 40, 20, 20, new Rgba32(80, 80, 80, 255));

IconHelper.ProcessImage(image, Color.FromBytes(200, 100, 50), 512, 0);

// The crop covers both patches, so the white one starts at (0,0) and the grey at (30,30).
Rgba32 white = image[5, 5];
Rgba32 grey = image[35, 35];

Assert.AreEqual(255, white.A, "A fully lit pixel should be fully covered.");
Assert.AreEqual(105, grey.A, "A midtone pixel should become partial coverage, not a darker colour.");

foreach (Rgba32 pixel in new[] { white, grey })
{
Assert.AreEqual(200, pixel.R, "Brightness must not survive in the colour channels.");
Assert.AreEqual(100, pixel.G);
Assert.AreEqual(50, pixel.B);
}
}

[TestMethod]
public void MultipliesSourceAlphaIntoTheCoverage()
{
// Coverage is the product of brightness and the source alpha, so a half transparent white
// pixel is half covered even though it is at full brightness.
using Image<Rgba32> image = TestImages.Blank(80, 80);
TestImages.FillRect(image, 10, 10, 20, 20, OpaqueWhite);
TestImages.FillRect(image, 10, 10, 20, 10, new Rgba32(255, 255, 255, 128));

IconHelper.ProcessImage(image, Color.FromBytes(0, 128, 255), 512, 0);

Assert.AreEqual(128, image[5, 5].A, "Source alpha should carry through into the coverage.");
Assert.AreEqual(255, image[5, 15].A, "The fully opaque half is unaffected.");
}

[TestMethod]
public void DropsUnlitArtworkFromTheCoverage()
{
// A black region sitting alongside a white one normalizes to intensity 0, which is now zero
// coverage rather than an opaque black patch. It must therefore also fall outside the crop,
// or the canvas would be padded out around artwork that is no longer visible.
using Image<Rgba32> image = TestImages.Blank(80, 80);
TestImages.FillRect(image, 10, 10, 20, 20, OpaqueWhite);
TestImages.FillRect(image, 10, 40, 20, 20, OpaqueBlack);

IconHelper.ProcessImage(image, Color.FromBytes(0, 255, 0), 512, 0);

Assert.AreEqual(20, image.Width, "The crop should ignore the unlit region entirely.");
Assert.AreEqual(20, image.Height);
Assert.AreEqual(255, image[5, 5].A, "The lit region survives at full coverage.");
}

[TestMethod]
public void PaintsEveryPixelTheFlatTargetColour()
{
// Nothing in the output may modulate the colour channels: whatever the source tones were,
// every pixel comes out as exactly the target colour, with the shape only in the alpha.
using Image<Rgba32> image = TestImages.Blank(80, 80);
TestImages.FillRect(image, 10, 10, 30, 30, new Rgba32(255, 255, 255, 255));
TestImages.FillRect(image, 20, 20, 30, 30, new Rgba32(80, 80, 80, 255));
TestImages.FillRect(image, 30, 30, 20, 20, new Rgba32(96, 96, 96, 255));

IconHelper.ProcessImage(image, Color.FromBytes(200, 100, 50), 512, 0);

for (int y = 0; y < image.Height; y++)
{
for (int x = 0; x < image.Width; x++)
{
Rgba32 pixel = image[x, y];
Assert.AreEqual(200, pixel.R, $"Pixel ({x},{y}) does not carry the flat target colour.");
Assert.AreEqual(100, pixel.G, $"Pixel ({x},{y}) does not carry the flat target colour.");
Assert.AreEqual(50, pixel.B, $"Pixel ({x},{y}) does not carry the flat target colour.");
}
}
}

[TestMethod]
Expand All @@ -189,7 +277,7 @@ public void FlattensMultiColouredArtworkToASingleHue()

IconHelper.ProcessImage(image, Color.FromBytes(0, 0, 255), 60, 0);

// Tinting with pure blue means no pixel may carry any red or green at all,
// Painting with pure blue means no pixel may carry any red or green at all,
// regardless of what colour it started as.
for (int y = 0; y < image.Height; y++)
{
Expand Down
Loading