diff --git a/CLAUDE.md b/CLAUDE.md index cb600ce..2b0421e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 → BlackWhite() → find max opaque luminance → tint by colour - → crop to alpha bounding box → pad to square → resize → pad to final size → SaveAsPng + Load → 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 @@ -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. @@ -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 diff --git a/DESCRIPTION.md b/DESCRIPTION.md index 78bad5a..1c0dbf6 100644 --- a/DESCRIPTION.md +++ b/DESCRIPTION.md @@ -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. diff --git a/IconHelper.Test/GoldMaster/Expected/colorful-icon_FF0000_48_2.png b/IconHelper.Test/GoldMaster/Expected/colorful-icon_FF0000_48_2.png index c37c282..07b8f86 100644 --- a/IconHelper.Test/GoldMaster/Expected/colorful-icon_FF0000_48_2.png +++ b/IconHelper.Test/GoldMaster/Expected/colorful-icon_FF0000_48_2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a7353a122834d4d2d5055dc736c02b667f735d9296f13f754cb7fe1ca437adfd -size 1060 +oid sha256:e67b26214effb493257aad7b17e6beda3d62e60613fd7bf72722322ac1d0037a +size 1046 diff --git a/IconHelper.Test/GoldMaster/Expected/midtone-grey-shape_3366CC_96_8.png b/IconHelper.Test/GoldMaster/Expected/midtone-grey-shape_3366CC_96_8.png index e9fc9af..24c5c51 100644 --- a/IconHelper.Test/GoldMaster/Expected/midtone-grey-shape_3366CC_96_8.png +++ b/IconHelper.Test/GoldMaster/Expected/midtone-grey-shape_3366CC_96_8.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:941203dc27c76428aaa92bcb7e811ed01b272e0a7c646e3fd9d2ffb2ea9b9b3a -size 2325 +oid sha256:442ae63e350d9790e78b8652794acd66cc396426507bcfdd40d9ca7a762d08fb +size 2106 diff --git a/IconHelper.Test/ProcessImageTests.cs b/IconHelper.Test/ProcessImageTests.cs index 304f5c0..b3f00b6 100644 --- a/IconHelper.Test/ProcessImageTests.cs +++ b/IconHelper.Test/ProcessImageTests.cs @@ -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 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 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 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 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 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] @@ -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++) { diff --git a/IconHelper/IconHelper.cs b/IconHelper/IconHelper.cs index 9663b1b..7241310 100644 --- a/IconHelper/IconHelper.cs +++ b/IconHelper/IconHelper.cs @@ -152,8 +152,9 @@ internal static BatchResult ProcessDirectory(Arguments args, Color color) } /// - /// Recolours an icon to a flat silhouette in the target colour, trims its transparent margins, - /// centres it on a square canvas and scales it down to at most pixels. + /// Reduces an icon to a coverage mask: every pixel carries the flat target colour and the shape + /// lives entirely in the alpha channel. The result is trimmed of its transparent margins, centred + /// on a square canvas and scaled down to at most pixels. /// The image is mutated in place. /// internal static void ProcessImage(Image image, Color color, int size, int padding) @@ -161,14 +162,20 @@ internal static void ProcessImage(Image image, Color color, int size, in Ensure.NotNull(image); // The semantic Color stores linear channels as doubles. Encode to sRGB bytes once here rather - // than per pixel, both for speed and so the tint below stays plain byte arithmetic. + // than per pixel, both for speed and so the pass below stays plain byte arithmetic. (byte colorR, byte colorG, byte colorB, byte _) = color.ToBytes(); - // RECOLOURING ALGORITHM + // COVERAGE ALGORITHM // - // Turns an arbitrary icon into a flat silhouette painted in a single target colour, - // while keeping the anti-aliased edges smooth: flatten to greyscale, normalize the - // brightness, then multiply through by the colour. + // Turns an arbitrary icon into a coverage mask painted in a single target colour: + // flatten to greyscale, normalize the brightness, then fold that brightness into the + // alpha channel while the colour channels are painted flat. + // + // The colour channels therefore carry no shape information at all. A pixel that used to + // come out as a dark shade of the target colour now comes out as the target colour at a + // proportionally lower alpha, so anti-aliased edges survive as partial coverage rather + // than as darkening. That is what lets the output composite correctly over any background + // instead of only over the black it was previously matted against. // // Flatten to greyscale. ImageSharp's BlackWhite filter is a colour matrix // (KnownFilterMatrices.BlackWhiteFilter) whose red, green and blue rows are all @@ -185,8 +192,8 @@ internal static void ProcessImage(Image image, Color color, int size, in // clamps to black at v <= 2/9 (~0.222) and to white at v >= 4/9 (~0.444). The output // is therefore near-binary: most pixels land on pure black or pure white, with only a // narrow band of true midtones along anti-aliased edges. Those midtones are exactly - // what the normalization and tint below preserve, and they are the reason maxValue is - // already 255 for most real icons. + // what the normalization below turns into partial coverage, and they are the reason + // maxValue is already 255 for most real icons. // // Note the filter has no alpha awareness beyond passing alpha through, so it also // rewrites the colour channels of fully transparent pixels (see the maximum @@ -198,17 +205,17 @@ internal static void ProcessImage(Image image, Color color, int size, in // Handle the all-black glyph case. A maxValue of 0 means every opaque pixel is pure // black, a solid silhouette carrying its shape entirely in the alpha channel. // The isBlack flag forces those pixels to full intensity in the pass below so the - // glyph takes the target colour. Without it the normalization would resolve to - // intensity 0 and the icon would come out invisible. + // glyph takes full coverage. Without it the normalization would resolve to intensity + // 0, which now collapses the alpha to 0 and the icon would come out fully transparent. bool isBlack = maxValue == 0; - PixelBounds bounds = TintAndMeasureBounds(image, maxValue, isBlack, colorR, colorG, colorB); + PixelBounds bounds = FlattenToCoverageAndMeasureBounds(image, maxValue, isBlack, colorR, colorG, colorB); if (bounds.IsEmpty) { // No artwork to crop around, so emit an empty square rather than trying to measure one. // The side comes from the source canvas so the downscale-only rule still applies, and - // every pixel is already rgba(0,0,0,0) by now, so resizing keeps it fully transparent. + // every pixel already has an alpha of 0 by now, so resizing keeps it fully transparent. int blankSize = Math.Min(Math.Max(image.Width, image.Height), size); image.Mutate(x => x.Resize(blankSize, blankSize)); return; @@ -247,11 +254,39 @@ private static byte FindBrightestOpaqueValue(Image image) } /// - /// Normalizes the brightness and multiplies through by the target colour, returning the bounding - /// box of the visible artwork. The two are done in one pass because it is already walking every - /// pixel, and the crop needs those bounds to trim the transparent margins. + /// The normalized brightness of a single pixel, which is what the coverage is scaled by. /// - private static PixelBounds TintAndMeasureBounds( + private static byte NormalizedIntensity(Rgba32 pixel, byte maxValue, bool isBlack) + { + if (pixel.A == 0) + { + // Transparent pixels are pinned to 0 rather than normalized. maxValue was sampled from + // opaque pixels only, so a transparent pixel whose R exceeds it would underflow the + // subtraction below and wrap around to a bright value. + return 0; + } + + if (isBlack) + { + // Every opaque pixel is pure black, so there is no tonal range to normalize against and + // they all take full intensity. See the isBlack comment in ProcessImage. + return 255; + } + + // Normalize by *offset*, not by scale: adding (255 - maxValue) to every pixel lifts the + // brightest opaque pixel to exactly 255 while preserving the absolute differences between + // tones, so anti-aliased edges keep their gradient instead of being stretched apart. A + // source whose brightest pixel is already 255 passes through unchanged. + return (byte)(255 - (maxValue - pixel.R)); + } + + /// + /// Paints the flat target colour across every pixel and folds the normalized brightness into the + /// alpha channel, returning the bounding box of the visible artwork. The two are done in one pass + /// because it is already walking every pixel, and the crop needs those bounds to trim the + /// transparent margins. + /// + private static PixelBounds FlattenToCoverageAndMeasureBounds( Image image, byte maxValue, bool isBlack, @@ -259,7 +294,7 @@ private static PixelBounds TintAndMeasureBounds( byte colorG, byte colorB) { - // Seeded inverted, so an image with nothing opaque in it leaves them that way and reports + // Seeded inverted, so an image with nothing visible in it leaves them that way and reports // itself as empty. int top = image.Height; int left = image.Width; @@ -276,37 +311,35 @@ private static PixelBounds TintAndMeasureBounds( { ref Rgba32 pixel = ref pixelRow[x]; - // Normalize by *offset*, not by scale: adding (255 - maxValue) to every - // pixel lifts the brightest opaque pixel to exactly 255 while preserving - // the absolute differences between tones, so anti-aliased edges keep - // their gradient instead of being stretched apart. A source whose - // brightest pixel is already 255 passes through unchanged. - byte newValue = (byte)(isBlack ? 255 : 255 - (maxValue - pixel.R)); - if (pixel.A != 0) + byte intensity = NormalizedIntensity(pixel, maxValue, isBlack); + + // Merge the brightness into the alpha. Coverage is the product of the two, + // so a half-lit pixel at full alpha and a fully lit pixel at half alpha both + // describe half coverage. Integer arithmetic throughout, so a fully lit, + // fully opaque pixel lands back on exactly 255 rather than a float rounding + // of it. + byte coverage = (byte)(pixel.A * intensity / 255); + + // Measure the bounds against the *merged* alpha, not the source alpha. An + // opaque but unlit pixel now contributes nothing visible, so including it + // would pad the crop out around artwork that is not there. + if (coverage != 0) { left = Math.Min(left, x); top = Math.Min(top, y); right = Math.Max(right, x); bottom = Math.Max(bottom, y); } - else - { - // Zero the colour of fully transparent pixels. Without this they keep - // whatever RGB the decoder left behind, and the Resize below blends - // that hidden colour into neighbouring pixels, producing a dark or - // off-colour halo around the icon. This also discards the overflowed - // newValue computed above for transparent pixels whose R exceeded - // maxValue (which was sampled from opaque pixels only). - newValue = 0; - } - // Multiply the target colour by the normalized intensity. Intensity 255 - // yields the colour exactly, intermediate values yield proportionally - // darker shades of it, which is what keeps edges anti-aliased. Alpha is - // deliberately left alone so the original transparency is preserved. - pixel.R = (byte)(newValue / 255f * colorR); - pixel.G = (byte)(newValue / 255f * colorG); - pixel.B = (byte)(newValue / 255f * colorB); + // Paint the target colour flat, transparent pixels included. Whatever RGB the + // decoder left in a transparent pixel, and equally a zeroed one, gives the + // Resize below 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 one colour is that colour. + pixel.R = colorR; + pixel.G = colorG; + pixel.B = colorB; + pixel.A = coverage; } } }); diff --git a/IconHelper/PixelBounds.cs b/IconHelper/PixelBounds.cs index 6a4359e..74b300e 100644 --- a/IconHelper/PixelBounds.cs +++ b/IconHelper/PixelBounds.cs @@ -3,16 +3,16 @@ namespace ktsu.IconHelper; /// -/// The bounding box of the non transparent pixels in an image, in inclusive pixel indices. +/// The bounding box of the visible pixels in an image, in inclusive pixel indices. /// -/// Index of the leftmost column containing an opaque pixel. -/// Index of the topmost row containing an opaque pixel. -/// Index of the rightmost column containing an opaque pixel. -/// Index of the bottommost row containing an opaque pixel. +/// Index of the leftmost column containing a visible pixel. +/// Index of the topmost row containing a visible pixel. +/// Index of the rightmost column containing a visible pixel. +/// Index of the bottommost row containing a visible pixel. internal readonly record struct PixelBounds(int Left, int Top, int Right, int Bottom) { /// - /// True when the image contained no opaque pixel at all. The bounds are seeded inverted, so + /// True when the image contained no visible pixel at all. The bounds are seeded inverted, so /// nothing having widened them leaves them that way. /// internal bool IsEmpty => Right < Left || Bottom < Top; diff --git a/README.md b/README.md index db37c34..d439b33 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ktsu.IconHelper -> A .NET command-line tool that batch-normalizes icon images by recoloring, trimming, squaring, and resizing them into consistent PNGs. +> 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. [![License](https://img.shields.io/github/license/ktsu-dev/IconHelper.svg?label=License&logo=nuget)](LICENSE.md) [![NuGet Version](https://img.shields.io/nuget/v/ktsu.IconHelper.svg?label=NuGet&logo=nuget)](https://www.nuget.org/packages/ktsu.IconHelper/) @@ -14,8 +14,12 @@ `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 -monochrome silhouette tinted with a colour of your choosing, trims away the transparent margins, -centres the artwork on a square canvas, and writes out a uniformly sized PNG. +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](https://github.com/SixLabors/ImageSharp), so it runs anywhere .NET does and needs no native image libraries or platform-specific dependencies. @@ -23,12 +27,12 @@ It is built on [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp), ## Features - **Batch Processing**: Processes every file in an input directory in a single run -- **Colour Tinting**: Flattens each image to a silhouette and tints it with any HTML/CSS colour value -- **Automatic Trimming**: Detects the bounding box of non-transparent pixels and crops to it +- **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 Preservation**: Writes 8-bit RGBA PNGs with transparency intact +- **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 ## Installation @@ -95,7 +99,8 @@ iconhelper -i ./icons -o ./out -c "#FF8800" # Three digit shorthand, equivalent to #FF8800 iconhelper -i ./icons -o ./out -c "#F80" -# Eight digit hex, with alpha +# 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 @@ -142,8 +147,8 @@ Done. 2 file(s) written, 1 failed. ## How It Works -The whole design follows from one goal: reduce artwork of unknown origin to a single-colour -silhouette without destroying the anti-aliased edges that make an icon look smooth at small sizes. +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. @@ -180,16 +185,27 @@ 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. -### 3. Normalize, then tint +### 3. Normalize, then merge the brightness into alpha -A second pass lifts each pixel to full intensity and multiplies through by the target colour: +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 -channel = intensity / 255 * targetChannel +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 @@ -200,21 +216,24 @@ 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 and the icon would come - out invisible. -- **Transparent pixels have their colour zeroed.** Whatever RGB the decoder left behind would - otherwise be blended outward by the resize in stage 5, producing a dark or off-colour halo around - the icon. - -Alpha is never modified, so the original transparency survives to the output. + 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. ### 4. Trim and square -The same pass that tints also accumulates the bounding box of the non-transparent pixels, since it -is already visiting every pixel. The image is cropped to that box, which discards whatever empty +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. @@ -270,7 +289,11 @@ for how many succeeded. Vector formats such as SVG are not supported. - The tool only ever shrinks artwork. Passing a `--size` larger than the source icon leaves it at its original size. -- Colour information in the source is discarded, so every icon becomes a single-colour silhouette. +- 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 `2` if anything failed. diff --git a/TAGS.md b/TAGS.md index 11ec329..bf2de3f 100644 --- a/TAGS.md +++ b/TAGS.md @@ -1 +1 @@ -.NET;C#;dotnet;csharp;icon;icons;icon processing;image processing;batch processing;cli;command line;tool;recolor;tint;silhouette;crop;trim;resize;padding;png;transparency;alpha;imagesharp;sixlabors;graphics +.NET;C#;dotnet;csharp;icon;icons;icon processing;image processing;batch processing;cli;command line;tool;recolor;tint;silhouette;coverage mask;alpha mask;crop;trim;resize;padding;png;transparency;alpha;imagesharp;sixlabors;graphics