<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Maxwell Dispatch]]></title><description><![CDATA[Maxwell Dispatch]]></description><link>https://maxwelldispatch.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 18:15:21 GMT</lastBuildDate><atom:link href="https://maxwelldispatch.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Will This Watermark Be Readable? A Per-Photo Contrast Check for Batch Image Processing in Swift]]></title><description><![CDATA[Relative luminance, contrast ratios, the opacity trap, and why the worst 10% of pixels matters more than the average.]]></description><link>https://maxwelldispatch.hashnode.dev/will-this-watermark-be-readable-a-per-photo-contrast-check-for-batch-image-processing-in-swift</link><guid isPermaLink="true">https://maxwelldispatch.hashnode.dev/will-this-watermark-be-readable-a-per-photo-contrast-check-for-batch-image-processing-in-swift</guid><category><![CDATA[iOS]]></category><category><![CDATA[Swift]]></category><category><![CDATA[image processing]]></category><category><![CDATA[Accessibility]]></category><category><![CDATA[algorithms]]></category><dc:creator><![CDATA[Maxwell]]></dc:creator><pubDate>Mon, 02 Nov 2026 02:20:48 GMT</pubDate><content:encoded><![CDATA[<p>When you put a watermark on one photo, you look at it. When you put the same watermark on a hundred photos, you don't. Somewhere in that batch there's a photo where your white text lands on a bright sky, or your dark text lands on a shadow, and the mark is effectively invisible.</p>
<p>You can't fix that by choosing a better default colour, because no single colour works on every photo. What you can do is check each photo after layout: measure the pixels that sit under the mark and decide whether the mark will be readable there. If it won't, flag the photo, switch the colour, or add a shadow.</p>
<p>This post builds that check in Swift with Core Image. It's short, it runs in a few milliseconds per photo, and the interesting part isn't the code but two decisions inside it: how you combine the background pixels, and how you account for opacity.</p>
<h2>The measure: WCAG contrast ratio</h2>
<p>We need a number that says "how distinguishable are these two colours to a human eye". The web accessibility world already has a standard one, the <a href="https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio">contrast ratio from WCAG 2.1</a>:</p>
<pre><code class="language-plaintext">ratio = (L_lighter + 0.05) / (L_darker + 0.05)
</code></pre>
<p>where <code>L</code> is <strong>relative luminance</strong>: the colour converted from sRGB to linear light, then weighted by how sensitive the eye is to each channel:</p>
<pre><code class="language-plaintext">L = 0.2126 R + 0.7152 G + 0.0722 B     (R, G, B linear, 0...1)
</code></pre>
<p>The ratio runs from 1:1 (identical) to 21:1 (black on white). WCAG asks for 4.5:1 for body text and 3:1 for large text. A watermark is usually large and isn't meant to be read like a paragraph, so I use <strong>3:1</strong> as the line between "readable" and "weak". That's a judgement call, not a standard for watermarks, and you can tune it.</p>
<p>One useful number falls out of the formula. White and black have equal contrast against a background whose luminance is about <strong>0.179</strong>. Below that, white text wins; above it, black text wins. Note that 0.179 is a <em>linear</em> value; in everyday sRGB terms, it's a mid grey of roughly 118 out of 255, a lot darker than the "50% grey" most people would guess.</p>
<p>In Swift:</p>
<pre><code class="language-swift">import Foundation

/// sRGB-encoded 0...255 channel to linear 0...1.
@inline(__always)
func linear(_ c: UInt8) -&gt; Double {
    let v = Double(c) / 255
    return v &lt;= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
}

func luminance(_ r: UInt8, _ g: UInt8, _ b: UInt8) -&gt; Double {
    0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}

func contrast(_ a: Double, _ b: Double) -&gt; Double {
    (max(a, b) + 0.05) / (min(a, b) + 0.05)
}
</code></pre>
<p>(The WCAG 2.1 text uses 0.03928 as the threshold in the linearisation; 0.04045 is the sRGB specification's value. For 8-bit input they give the same result.)</p>
<h2>Step 1: Sample the pixels under the mark</h2>
<p>You already know where the watermark goes, because your layout code computed its rectangle. Take that rectangle, cut it out of the photo, and shrink it to a small grid, say 24 × 24. That's 576 samples, which is plenty to describe the background and costs almost nothing to analyse.</p>
<p>Two details matter here.</p>
<p><strong>Core Image's origin is bottom-left.</strong> If your layout uses UIKit coordinates (top-left origin), flip the rectangle before cropping, or you'll measure the wrong corner of the photo. This is the most common bug in this kind of code, and it fails silently: you get plausible numbers for the wrong pixels.</p>
<p><strong>Clamp before you resample.</strong> Outside a cropped image, Core Image sees transparent pixels. A Lanczos downscale near the edges of the crop blends those in, which darkens your edge samples. Calling <code>clampedToExtent()</code> first repeats the edge pixels outward so the filter has real data to work with.</p>
<pre><code class="language-swift">import CoreImage
import CoreImage.CIFilterBuiltins

let sharedContext = CIContext()   // create once, reuse for the whole batch

/// Returns `grid` x `grid` RGBA8 sRGB samples of `rect`.
/// `rect` is in UIKit coordinates: pixels, top-left origin.
func backgroundSamples(of image: CIImage, under rect: CGRect,
                       grid: Int = 24) -&gt; [UInt8]? {
    let extent = image.extent
    let ciRect = CGRect(x: extent.minX + rect.minX,
                        y: extent.maxY - rect.maxY,      // flip Y
                        width: rect.width, height: rect.height)
        .intersection(extent)
    guard !ciRect.isNull, ciRect.width &gt;= 1, ciRect.height &gt;= 1 else { return nil }

    let region = image
        .cropped(to: ciRect)
        .clampedToExtent()
        .transformed(by: CGAffineTransform(translationX: -ciRect.minX, y: -ciRect.minY))

    let scaleY = CGFloat(grid) / ciRect.height
    let scaleX = CGFloat(grid) / ciRect.width

    let resize = CIFilter.lanczosScaleTransform()
    resize.inputImage = region
    resize.scale = Float(scaleY)
    resize.aspectRatio = Float(scaleX / scaleY)

    let bounds = CGRect(x: 0, y: 0, width: grid, height: grid)
    guard let small = resize.outputImage?.cropped(to: bounds),
          let sRGB = CGColorSpace(name: CGColorSpace.sRGB) else { return nil }

    var pixels = [UInt8](repeating: 0, count: grid * grid * 4)
    sharedContext.render(small, toBitmap: &amp;pixels, rowBytes: grid * 4,
                         bounds: bounds, format: .RGBA8, colorSpace: sRGB)
    return pixels
}
</code></pre>
<p>Rendering into sRGB on purpose matters: the WCAG formula is defined on sRGB values, so we ask Core Image to convert whatever the photo's colour space is (often Display P3 on iPhone) into sRGB before we read the bytes.</p>
<h2>Step 2: Account for opacity</h2>
<p>Most watermarks aren't opaque. At 85% opacity, a white mark over a dark-blue sky isn't white; it's 85% white and 15% blue. That blended colour is what the eye compares with the background, and it always has <em>less</em> contrast than the solid colour would.</p>
<p>So instead of comparing "white" with the background, compare the blended mark colour with the background, pixel by pixel:</p>
<pre><code class="language-swift">struct RGB { var r, g, b: Double }   // 0...255, sRGB-encoded

func blended(_ mark: RGB, over bg: RGB, opacity a: Double) -&gt; RGB {
    RGB(r: a * mark.r + (1 - a) * bg.r,
        g: a * mark.g + (1 - a) * bg.g,
        b: a * mark.b + (1 - a) * bg.b)
}

func lum(_ c: RGB) -&gt; Double {
    luminance(UInt8(c.r.rounded()), UInt8(c.g.rounded()), UInt8(c.b.rounded()))
}
</code></pre>
<p>This blends in encoded sRGB, which matches how an 8-bit sRGB drawing context composites by default. If your renderer blends in linear light, blend linear values instead. The decision rarely changes, but the numbers will shift a little.</p>
<h2>Step 3: Use the worst case, not the average</h2>
<p>Here's the decision that matters most. The easy version is: average the background, compute one contrast ratio, done. Core Image even has a filter for it (<code>CIAreaAverage</code>).</p>
<p>The average hides exactly the photos you're trying to catch. Picture a watermark that straddles the edge of a sunlit white wall and a deep shadow. The average of those two is a middling grey, and dark text against middling grey looks fine on paper. On the actual photo, half the mark sits on the shadow and disappears.</p>
<p>So compute the contrast for every sample, sort, and look at the <strong>10th percentile</strong>: the contrast that 90% of the mark area beats. If the worst tenth of the background is too close to the mark colour, part of the mark will be unreadable no matter how good the average looks.</p>
<pre><code class="language-swift">struct MarkCheck {
    enum Tone { case light, dark }
    let tone: Tone
    let worstContrast: Double          // 10th percentile across the samples
    var isReadable: Bool { worstContrast &gt;= 3.0 }
}

func percentile(_ values: [Double], _ p: Double) -&gt; Double {
    let sorted = values.sorted()
    let index = Int((Double(sorted.count - 1) * p).rounded())
    return sorted[index]
}

func checkMark(samples: [UInt8], opacity: Double) -&gt; MarkCheck {
    let light = RGB(r: 255, g: 255, b: 255)
    let dark  = RGB(r: 26, g: 26, b: 26)           // #1A1A1A rather than pure black

    var lightRatios: [Double] = []
    var darkRatios: [Double] = []
    lightRatios.reserveCapacity(samples.count / 4)
    darkRatios.reserveCapacity(samples.count / 4)

    for i in stride(from: 0, to: samples.count, by: 4) {
        let bg = RGB(r: Double(samples[i]), g: Double(samples[i + 1]), b: Double(samples[i + 2]))
        let bgL = lum(bg)
        lightRatios.append(contrast(lum(blended(light, over: bg, opacity: opacity)), bgL))
        darkRatios.append(contrast(lum(blended(dark, over: bg, opacity: opacity)), bgL))
    }

    let lightWorst = percentile(lightRatios, 0.10)
    let darkWorst = percentile(darkRatios, 0.10)
    return lightWorst &gt;= darkWorst
        ? MarkCheck(tone: .light, worstContrast: lightWorst)
        : MarkCheck(tone: .dark, worstContrast: darkWorst)
}
</code></pre>
<p>The 10th percentile is a tunable. A mark made of thick letters survives a few bad pixels; a thin script font doesn't. If you use hairline fonts, look at the 20th or 25th percentile instead.</p>
<h2>Mean versus worst case, in numbers</h2>
<p>To see whether the percentile earns its keep, I ran the same check (ported to Python) on three synthetic backgrounds, with a white mark and a #1A1A1A mark at 85% opacity:</p>
<table>
<thead>
<tr>
<th>Background</th>
<th>Mark</th>
<th>Mean-based ratio</th>
<th>Worst 10% ratio</th>
<th>Verdict</th>
</tr>
</thead>
<tbody><tr>
<td>Bright sky gradient</td>
<td>white</td>
<td>1.47</td>
<td>1.32</td>
<td>weak</td>
</tr>
<tr>
<td>Bright sky gradient</td>
<td>dark</td>
<td>7.81</td>
<td>7.04</td>
<td>readable</td>
</tr>
<tr>
<td>Dark, noisy foliage</td>
<td>white</td>
<td>8.14</td>
<td>6.12</td>
<td>readable</td>
</tr>
<tr>
<td>Dark, noisy foliage</td>
<td>dark</td>
<td>1.56</td>
<td>1.23</td>
<td>weak</td>
</tr>
<tr>
<td>Half white wall, half shadow</td>
<td>white</td>
<td>2.98</td>
<td>1.15</td>
<td>weak</td>
</tr>
<tr>
<td>Half white wall, half shadow</td>
<td>dark</td>
<td><strong>4.08</strong></td>
<td><strong>1.14</strong></td>
<td><strong>weak</strong></td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/6adb7bc9c5fd6a2811bc289b/a0fe6a6b-7dbd-42e3-8e36-276f0846c3a1.png" alt="Six tiles: a light sky, dark foliage, and a half-white half-dark wall, each with a white and a dark watermark. Under each, the mean-based contrast ratio and the worst-10% ratio." style="display:block;margin:0 auto" />

<p>The first two rows are the easy cases, and both methods agree. The foliage shows texture costing you something: the noise pulls the worst case well below the mean, though white still passes comfortably.</p>
<p>The last row is the reason for this post. The average says dark text passes at about 4:1. The worst-case check says neither colour works: whichever you pick, roughly half the mark sits on a background of nearly its own brightness. A mean-based check would ship that photo with a half-invisible watermark.</p>
<h2>What to do when neither colour passes</h2>
<p>When both tones come back weak, the background is split, and changing the colour can't help. You have three honest options:</p>
<ol>
<li><strong>Add a shadow or outline.</strong> A soft dark shadow under light text creates its own local background. Size it relative to the image, not in points, or it vanishes on large photos.</li>
<li><strong>Add a backing plate.</strong> A rounded rectangle at 30–40% opacity behind the text guarantees contrast. It's more visible, which is sometimes the point.</li>
<li><strong>Move the mark.</strong> If your layout allows another corner, run the check there too and pick the best one. With 576 samples per position, checking four corners is still cheap.</li>
</ol>
<p>For a batch tool, I'd add a fourth: <strong>tell the person</strong>. Flagging "these 7 photos have a weak watermark" before export is more useful than silently guessing.</p>
<h2>Wiring it into a batch</h2>
<p>Per photo, the cost is one small Core Image render and a few thousand arithmetic operations. Use one <code>CIContext</code> for the whole batch (creating a context is the expensive part), load each photo, compute the layout, run the check, then decide:</p>
<pre><code class="language-swift">func review(_ images: [CIImage], markRect: (CIImage) -&gt; CGRect,
            opacity: Double) -&gt; [(index: Int, check: MarkCheck)] {
    images.enumerated().compactMap { index, image in
        guard let samples = backgroundSamples(of: image, under: markRect(image)) else {
            return nil
        }
        return (index, checkMark(samples: samples, opacity: opacity))
    }
}

// Later:
// let weak = review(photos, markRect: layout, opacity: 0.85).filter { !$0.check.isReadable }
</code></pre>
<p>If you're doing this over large photos, load them downsampled for the check. You're measuring a 24 × 24 grid; you don't need the full 48 megapixels in memory to do it.</p>
<h2>Takeaways</h2>
<ul>
<li>Measure the pixels under the mark, after layout, in sRGB.</li>
<li>Compare the <em>blended</em> mark colour with the background, because opacity always costs contrast.</li>
<li>Judge by a low percentile, not the average. Split backgrounds are where watermarks quietly disappear, and the average is blind to them.</li>
<li>When neither colour passes, change the treatment (shadow, plate, position), not the colour.</li>
</ul>
<p>I build an iPhone app called BatchMark, which applies one text or logo watermark, with your chosen position, size, opacity, font and spacing, to a whole set of photos at once. A setup that works on the first photo but not the fortieth is exactly the failure that a batch makes easy to miss, which is why I've been thinking about checks like this one. If you want to see the batch flow itself, <a href="https://batchmark.dispatchcat.com/en/features/batch-watermark/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=round3">here's how batch watermarking works in the app</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Finding a Photo's Main Colour in Swift: Why the Average Lies, and k-means in Lab Doesn't]]></title><description><![CDATA[Downsampling, a proper colour space, a seeded k-means, and a small scoring rule that picks the colour people actually see.]]></description><link>https://maxwelldispatch.hashnode.dev/finding-a-photo-s-main-colour-in-swift-why-the-average-lies-and-k-means-in-lab-doesn-t</link><guid isPermaLink="true">https://maxwelldispatch.hashnode.dev/finding-a-photo-s-main-colour-in-swift-why-the-average-lies-and-k-means-in-lab-doesn-t</guid><category><![CDATA[image processing]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[color]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Swift]]></category><dc:creator><![CDATA[Maxwell]]></dc:creator><pubDate>Fri, 30 Oct 2026 02:13:12 GMT</pubDate><content:encoded><![CDATA[<p>Here's a task that sounds like a one-liner: given a photo, return its main colour. It comes up in my iPhone app, which turns a photo into a colour card, and the obvious one-liner gives the wrong answer on most real photos.</p>
<p>This post walks through what went wrong, the approach that works, and Swift code for each step. Nothing here needs a third-party library. It's Core Graphics, some arithmetic, and about 120 lines of Swift.</p>
<h2>The obvious approach: average every pixel</h2>
<p>Core Image ships a filter for exactly this, <code>CIAreaAverage</code>. Feed it an image and an extent, and it gives you back a 1×1 image whose single pixel is the mean colour. It's fast, it runs on the GPU, and for a photo of a red wall it works perfectly.</p>
<p>Now try a beach photo: blue sky on top, teal sea in the middle, sand at the bottom, a row of orange umbrellas.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb7bc9c5fd6a2811bc289b/b290e1e5-cb36-4f19-9a99-549d010cea34.png" alt="A synthetic beach photo next to two results: the average colour is a single muddy grey-teal, while k-means in Lab gives five swatches (sky blue, sand, sea teal, orange, brown) with the sky blue outlined as dominant." style="display:block;margin:0 auto" />

<p>I built that test image on purpose so I'd know the right answer. The average came out as roughly <code>rgb(142, 168, 173)</code>, a dull grey-teal. That colour isn't anywhere in the picture. Averaging blends opposite hues together, and the more varied a photo is, the closer the result drifts towards grey.</p>
<p>What a person means by "the main colour" isn't the mean. It's the biggest group of similar pixels. That's a clustering problem, and the classic tool for it is k-means.</p>
<h2>Step 1: Shrink the photo first</h2>
<p>You don't need 12 million pixels to find a colour. A 64×64 sample (4,096 pixels) is plenty, and it turns k-means from a slow job into one that finishes in a few milliseconds.</p>
<p>Drawing into a small <code>CGContext</code> does the downsampling and gives you a predictable pixel format at the same time. Using an sRGB context also means wide-gamut images get converted for you, so the maths later only has to handle one colour space.</p>
<pre><code class="language-swift">import CoreGraphics
import Foundation

func samplePixels(from image: CGImage, side: Int = 64) -&gt; [Lab] {
    var buffer = [UInt8](repeating: 0, count: side * side * 4)
    let sRGB = CGColorSpace(name: CGColorSpace.sRGB)!

    let drew = buffer.withUnsafeMutableBytes { raw -&gt; Bool in
        guard let context = CGContext(
            data: raw.baseAddress,
            width: side,
            height: side,
            bitsPerComponent: 8,
            bytesPerRow: side * 4,
            space: sRGB,
            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
        ) else { return false }
        context.interpolationQuality = .medium
        context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side))
        return true
    }
    guard drew else { return [] }

    var pixels: [Lab] = []
    pixels.reserveCapacity(side * side)
    for i in stride(from: 0, to: buffer.count, by: 4) where buffer[i + 3] &gt; 200 {
        pixels.append(Lab(r: buffer[i], g: buffer[i + 1], b: buffer[i + 2]))
    }
    return pixels
}
</code></pre>
<p>Squashing the aspect ratio to a square is fine here. We care about how much of each colour there is, not where it sits. The alpha check skips mostly transparent pixels, which matters if you ever run this on a PNG with a cut-out.</p>
<p>One tip: if your source is a full-size file on disk, make a thumbnail with ImageIO's <code>CGImageSourceCreateThumbnailAtIndex</code> first and pass that in, so you never decode the full image just to throw most of it away.</p>
<h2>Step 2: Cluster in Lab, not RGB</h2>
<p>k-means groups points by distance. In RGB, distance doesn't match what your eye sees: two greens that look nearly identical can be further apart in RGB than a green and a grey that look nothing alike. CIE Lab was designed so that straight-line distance roughly tracks perceived difference, which is exactly what clustering needs.</p>
<p>The conversion is sRGB → linear RGB → XYZ (D65 white point) → Lab:</p>
<pre><code class="language-swift">struct Lab {
    var l: Double
    var a: Double
    var b: Double
}

private func toLinear(_ c: Double) -&gt; Double {
    c &lt;= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
}

private func toGamma(_ c: Double) -&gt; Double {
    c &lt;= 0.0031308 ? c * 12.92 : 1.055 * pow(c, 1 / 2.4) - 0.055
}

private let epsilon = 216.0 / 24389.0
private let kappa = 24389.0 / 27.0
private let white = (x: 0.95047, y: 1.0, z: 1.08883)   // D65

extension Lab {
    init(r: UInt8, g: UInt8, b: UInt8) {
        let rl = toLinear(Double(r) / 255)
        let gl = toLinear(Double(g) / 255)
        let bl = toLinear(Double(b) / 255)

        let x = (0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl) / white.x
        let y = (0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl) / white.y
        let z = (0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl) / white.z

        func f(_ t: Double) -&gt; Double { t &gt; epsilon ? cbrt(t) : (kappa * t + 16) / 116 }
        let fx = f(x), fy = f(y), fz = f(z)

        self.init(l: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz))
    }

    /// Back to 8-bit sRGB, clamped to the displayable range.
    var rgb: (r: UInt8, g: UInt8, b: UInt8) {
        let fy = (l + 16) / 116
        let fx = fy + a / 500
        let fz = fy - b / 200
        func inverse(_ t: Double) -&gt; Double {
            let cubed = t * t * t
            return cubed &gt; epsilon ? cubed : (116 * t - 16) / kappa
        }
        let x = inverse(fx) * white.x
        let y = inverse(fy) * white.y
        let z = inverse(fz) * white.z

        let r =  3.2404542 * x - 1.5371385 * y - 0.4985314 * z
        let g = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z
        let b =  0.0556434 * x - 0.2040259 * y + 1.0572252 * z

        func byte(_ c: Double) -&gt; UInt8 {
            UInt8((min(max(toGamma(c), 0), 1) * 255).rounded())
        }
        return (byte(r), byte(g), byte(b))
    }

    var chroma: Double { (a * a + b * b).squareRoot() }
}
</code></pre>
<p>I checked this pair of functions by converting every pixel of a test image to Lab and back. The worst error was far below one 8-bit step, so the round trip is effectively lossless.</p>
<h2>Step 3: k-means with a seeded k-means++ start</h2>
<p>Plain k-means picks random starting centres, which has two problems for a UI feature. First, bad starts give bad clusters. Second, the same photo can give a different answer every time you open it, which looks like a bug to the user.</p>
<p>k-means++ fixes the first problem by spreading the starting centres out: each new centre is picked with probability proportional to its squared distance from the nearest existing one. A tiny seeded random generator fixes the second.</p>
<pre><code class="language-swift">struct SplitMix64: RandomNumberGenerator {
    private var state: UInt64
    init(seed: UInt64) { state = seed }

    mutating func next() -&gt; UInt64 {
        state &amp;+= 0x9E3779B97F4A7C15
        var z = state
        z = (z ^ (z &gt;&gt; 30)) &amp;* 0xBF58476D1CE4E5B9
        z = (z ^ (z &gt;&gt; 27)) &amp;* 0x94D049BB133111EB
        return z ^ (z &gt;&gt; 31)
    }
}

struct Cluster {
    var center: Lab
    var count: Int
}

private func distanceSquared(_ p: Lab, _ q: Lab) -&gt; Double {
    let dl = p.l - q.l, da = p.a - q.a, db = p.b - q.b
    return dl * dl + da * da + db * db
}

func kMeans(_ points: [Lab], k: Int, iterations: Int = 12, seed: UInt64 = 42) -&gt; [Cluster] {
    guard k &gt; 0, points.count &gt;= k else { return [] }
    var rng = SplitMix64(seed: seed)

    // k-means++ seeding
    var centers = [points[Int.random(in: 0..&lt;points.count, using: &amp;rng)]]
    var nearest = points.map { distanceSquared($0, centers[0]) }
    while centers.count &lt; k {
        let total = nearest.reduce(0, +)
        guard total &gt; 0 else { break }          // every pixel is identical
        var target = Double.random(in: 0..&lt;total, using: &amp;rng)
        var chosen = points.count - 1
        for (i, d) in nearest.enumerated() {
            target -= d
            if target &lt;= 0 { chosen = i; break }
        }
        centers.append(points[chosen])
        for i in points.indices {
            nearest[i] = min(nearest[i], distanceSquared(points[i], points[chosen]))
        }
    }

    // Lloyd iterations
    var assignment = [Int](repeating: -1, count: points.count)
    var counts = [Int](repeating: 0, count: centers.count)
    for _ in 0..&lt;iterations {
        var changed = false
        for (i, p) in points.enumerated() {
            var best = 0
            var bestDistance = Double.infinity
            for (c, center) in centers.enumerated() {
                let d = distanceSquared(p, center)
                if d &lt; bestDistance { bestDistance = d; best = c }
            }
            if assignment[i] != best { assignment[i] = best; changed = true }
        }

        var sums = [Lab](repeating: Lab(l: 0, a: 0, b: 0), count: centers.count)
        counts = [Int](repeating: 0, count: centers.count)
        for (i, p) in points.enumerated() {
            let c = assignment[i]
            sums[c].l += p.l; sums[c].a += p.a; sums[c].b += p.b
            counts[c] += 1
        }
        for c in centers.indices where counts[c] &gt; 0 {
            let n = Double(counts[c])
            centers[c] = Lab(l: sums[c].l / n, a: sums[c].a / n, b: sums[c].b / n)
        }
        if !changed { break }
    }

    return zip(centers, counts)
        .map { Cluster(center: $0, count: $1) }
        .sorted { $0.count &gt; $1.count }
}
</code></pre>
<p>With 4,096 points, k = 5 and at most 12 iterations, this is around 250,000 distance checks. That's nothing. It usually converges well before the iteration cap.</p>
<p>Why k = 5? It's a trade-off. Too few and the sea and sky merge into one blue. Too many and you split one wall into "lit" and "shadow" versions. Five has been a good default for photos; for a palette strip you might want six or seven.</p>
<h2>Step 4: Decide what "main" means</h2>
<p>On the beach image, the clusters came out as sky blue (about 42% of pixels), sand (36%), sea teal (11%), umbrella orange (7%) and a small brown group from the poles and the edges where colours blend.</p>
<p>The biggest cluster wins here, and that's the right answer. But on many real photos, the biggest cluster is something dull: a grey pavement, a white wall, a dark shadow. People don't describe a photo of a red door on a grey street as "grey". They notice the colour.</p>
<p>So I score clusters by size, weighted by how colourful they are:</p>
<pre><code class="language-swift">func dominantColor(in clusters: [Cluster]) -&gt; Lab? {
    func score(_ cluster: Cluster) -&gt; Double {
        let colourfulness = 0.35 + min(cluster.center.chroma, 60) / 60
        return Double(cluster.count) * colourfulness
    }
    return clusters.max { score($0) &lt; score($1) }?.center
}
</code></pre>
<p>A fully grey cluster keeps 35% of its weight; a strongly coloured one gets up to 1.35×. The constants are taste, not science, so tune them on your own test photos. On the beach image the scoring doesn't change the winner, but it pulls the small orange cluster level with the much bigger teal one, which is closer to how a person ranks them.</p>
<p>A couple of guards I'd add in production:</p>
<ul>
<li><strong>Very dark or very bright clusters.</strong> Near-black and near-white have low chroma anyway, but you may want to skip clusters with <code>l &lt; 8</code> or <code>l &gt; 95</code> completely.</li>
<li><strong>Black-and-white photos.</strong> If every cluster has low chroma, don't force a colour. Return the biggest cluster and let it be grey. That's honest.</li>
</ul>
<h2>Putting it together</h2>
<pre><code class="language-swift">func mainColor(of image: CGImage) -&gt; (r: UInt8, g: UInt8, b: UInt8)? {
    let pixels = samplePixels(from: image)
    let clusters = kMeans(pixels, k: 5)
    return dominantColor(in: clusters)?.rgb
}
</code></pre>
<p>Because the seed is fixed and the input is downsampled the same way each time, the same photo always returns the same colour.</p>
<h2>What about CIKMeans?</h2>
<p>Core Image has had a <code>CIKMeans</code> filter since iOS 14. It runs on the GPU and returns the cluster means as a small image. It's a good choice if you're already in a Core Image pipeline. Two things to know: it clusters in the working colour space, not a perceptual one, so you may see the RGB distance problem above; and reading the result back still means rendering a tiny image into a bitmap. For a few thousand pixels, the CPU version above is fast enough, easy to debug, and gives you control over the distance and the scoring.</p>
<h2>Where this ended up</h2>
<p>This is the kind of problem behind the Color Walk cards in BatchMark, my iPhone app for batch watermarks, frames and colour cards. You pick a photo, the app pulls out its colours, and you add a place and a date. Everything runs on the device, which is one more reason a small sample size matters. If you're curious what the finished cards look like, <a href="https://batchmark.dispatchcat.com/en/features/color-walk/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=round2">the Color Walk page has examples</a>.</p>
<p>Whether or not you build colour cards, the general lesson holds for many image tasks. Shrink the input before you do anything clever, do the maths in a space that matches human perception, make the result deterministic, and test on images where you already know the right answer. A synthetic test image like the beach scene takes five minutes to make and catches most mistakes before a user does.</p>
<hr />
<p><em>Disclosure: I'm an independent developer, and BatchMark (灵感图像大师 in Chinese) is my own app. It's free to download on the App Store, with optional paid features.</em></p>
]]></content:encoded></item><item><title><![CDATA[Tiled Watermarks on iOS: Rotating a Text Grid Without Leaving Bare Corners]]></title><description><![CDATA[A small geometry fix, a Core Graphics snippet, and the memory trap that bites when you process 100 photos in a row.]]></description><link>https://maxwelldispatch.hashnode.dev/tiled-watermarks-on-ios-rotating-a-text-grid-without-leaving-bare-corners</link><guid isPermaLink="true">https://maxwelldispatch.hashnode.dev/tiled-watermarks-on-ios-rotating-a-text-grid-without-leaving-bare-corners</guid><category><![CDATA[iOS]]></category><category><![CDATA[Swift]]></category><category><![CDATA[image processing]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[buildinpublic]]></category><dc:creator><![CDATA[Maxwell]]></dc:creator><pubDate>Tue, 27 Oct 2026 10:44:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6adb7bc9c5fd6a2811bc289b/c503c48f-2bbb-41a9-9484-b95c971bce11.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've ever drawn a repeated diagonal text watermark on an image, you've probably had this moment: the preview looks great, then you try a wide photo at a steep angle and one corner is completely clean. Anyone who wanted to remove the mark just crops to that corner.</p>
<p>This post walks through why it happens, a fix that takes one line of maths, a working Swift snippet, and a few things that matter once you move from one photo to a whole batch. I work on a photo app for iPhone, and tiled marks are one of the features where the details matter, because the entire point is that there should be no clean area left.</p>
<h2>The bug: your grid doesn't cover the rotated rectangle</h2>
<p>The obvious approach is: loop over x from 0 to the image width, loop over y from 0 to the image height, draw the text at each step, and rotate the context first.</p>
<p>The problem is that rotating the context also rotates your loop's bounds. You've drawn a grid that covers a width × height rectangle, then tilted it. The tilted rectangle no longer lines up with the image, so triangles at two opposite corners stay empty.</p>
<p>How bad is it? I checked it numerically: for a 6000 × 4000 photo with a 30° tilt, roughly 18% of the image's area falls outside the rotated width × height grid. That's nearly a fifth of the picture with nothing on it.</p>
<h2>The fix: tile a square the size of the diagonal</h2>
<p>The fix is to stop thinking about the image's own size. Instead, tile a <strong>square whose side equals the image's diagonal</strong>, centred on the image, and rotate that.</p>
<p>Why does it always work? The diagonal of a rectangle is the diameter of the smallest circle that contains it. A square with that side length contains the same circle, and a circle is the same shape at every angle. So no matter how you rotate the square around the image's centre, the rectangle stays inside it.</p>
<p>I checked this too: I sampled random points in a 6000 × 4000 rectangle at 36 angles from 0° to 175°, rotated them into the grid's frame, and counted any that fell outside the square. The count was zero.</p>
<p>The cost is that you draw more tiles than you strictly need (roughly the square's area over the rectangle's). For a 3:2 photo that is a bit over twice as many draw calls. For text, it's nothing.</p>
<h2>The code</h2>
<p>Here's a version in Swift using <code>UIGraphicsImageRenderer</code>. It's meant to be read and adapted, not pasted blindly.</p>
<pre><code class="language-swift">import UIKit

struct TileStyle {
    var text: String
    var angle: CGFloat = -.pi / 6      // 30° counter-clockwise
    var opacity: CGFloat = 0.30
    var fontScale: CGFloat = 0.025      // font size as a fraction of image width
    var gapX: CGFloat = 1.0             // horizontal gap, in text widths
    var rowSpacing: CGFloat = 2.2       // row step, in text heights
}

func applyTiledWatermark(to image: UIImage, style: TileStyle) -&gt; UIImage {
    // Work in pixels: one renderer point = one pixel.
    let pixelSize = CGSize(width: image.size.width * image.scale,
                           height: image.size.height * image.scale)

    let format = UIGraphicsImageRendererFormat()
    format.scale = 1
    format.opaque = true

    let renderer = UIGraphicsImageRenderer(size: pixelSize, format: format)

    return renderer.image { context in
        image.draw(in: CGRect(origin: .zero, size: pixelSize))

        let fontSize = pixelSize.width * style.fontScale

        let shadow = NSShadow()
        shadow.shadowColor = UIColor.black.withAlphaComponent(style.opacity)
        shadow.shadowBlurRadius = fontSize * 0.1
        shadow.shadowOffset = CGSize(width: 0, height: fontSize * 0.04)

        let attributes: [NSAttributedString.Key: Any] = [
            .font: UIFont.systemFont(ofSize: fontSize, weight: .semibold),
            .foregroundColor: UIColor.white.withAlphaComponent(style.opacity),
            .shadow: shadow
        ]

        let label = style.text as NSString
        let textSize = label.size(withAttributes: attributes)
        let stepX = textSize.width * (1 + style.gapX)
        let stepY = textSize.height * style.rowSpacing

        let diagonal = hypot(pixelSize.width, pixelSize.height)
        let half = diagonal / 2

        let cg = context.cgContext
        cg.translateBy(x: pixelSize.width / 2, y: pixelSize.height / 2)
        cg.rotate(by: style.angle)

        var row = 0
        var y = -half - textSize.height
        while y &lt; half {
            let shift = row.isMultiple(of: 2) ? 0 : stepX / 2
            var x = -half - stepX + shift
            while x &lt; half {
                label.draw(at: CGPoint(x: x, y: y), withAttributes: attributes)
                x += stepX
            }
            y += stepY
            row += 1
        }
    }
}
</code></pre>
<p>A few notes on the choices in there.</p>
<p><strong>Font size is a fraction of image width.</strong> If you hard-code 36 points, the mark looks tiny on a 6000-pixel photo and huge on a 800-pixel one. Scaling with the width makes a batch look consistent, which is half the reason people use a batch tool at all.</p>
<p><strong>Rows are staggered.</strong> Offsetting every second row by half a step breaks up straight vertical lines, so the pattern reads as a texture instead of a spreadsheet. It also makes it harder to find a clean vertical strip.</p>
<p><strong>White text with a soft shadow.</strong> White alone disappears on bright photos. A shadow in the same low opacity keeps the text readable on both light and dark areas without being heavy. A fixed-colour watermark will always lose somewhere, so adding the shadow is a cheap hedge.</p>
<h2>The memory trap</h2>
<p>The line that matters most in that snippet is <code>format.scale = 1</code>.</p>
<p><code>UIGraphicsImageRenderer</code> defaults to the device's screen scale, which is 3 on most modern iPhones. If you create a renderer with the size of a 4000 × 3000 photo and don't set the scale, you're asking for a 12000 × 9000 bitmap. That's about 430 MB for a single frame. You'll see a memory warning, or the system will quietly kill your app.</p>
<p>The fix is what the snippet does: convert to pixel dimensions yourself and render at scale 1.</p>
<p>The second memory problem shows up in batches. If you process 100 photos in a loop and keep the results as <code>UIImage</code>s, you're holding 100 decoded bitmaps. Write each result to disk and let it go:</p>
<pre><code class="language-swift">func exportBatch(_ urls: [URL], style: TileStyle, to folder: URL) throws {
    for (index, url) in urls.enumerated() {
        try autoreleasepool {
            guard let data = try? Data(contentsOf: url),
                  let image = UIImage(data: data) else { return }

            let marked = applyTiledWatermark(to: image, style: style)
            guard let jpeg = marked.jpegData(compressionQuality: 0.92) else { return }

            let out = folder.appendingPathComponent(String(format: "%03d.jpg", index + 1))
            try jpeg.write(to: out, options: .atomic)
        }
    }
}
</code></pre>
<p><code>autoreleasepool</code> makes sure the temporary buffers from each iteration are freed at the end of that iteration, not at the end of the loop. For a larger run you'll also want to cap how many photos are in flight at once. Two or three is usually plenty; the machine is not the bottleneck as much as peak memory.</p>
<p>For on-screen previews, don't render at full size. Use ImageIO to decode a smaller version:</p>
<pre><code class="language-swift">func downsampledImage(at url: URL, maxPixel: Int) -&gt; UIImage? {
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixel
    ]
    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
          let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
    else { return nil }
    return UIImage(cgImage: cg)
}
</code></pre>
<p>The <code>kCGImageSourceCreateThumbnailWithTransform</code> flag matters: it applies the photo's orientation, so portrait shots don't come out sideways.</p>
<h2>How to test it</h2>
<p>You can write a test for "no clean corner" without any image comparison library. Render the watermark onto a solid mid-grey image, then split the result into a grid of, say, 64 × 64 pixel blocks and assert that <strong>every block contains at least some pixels that differ from the grey</strong>. If a corner is bare, the test fails and tells you which block.</p>
<p>Run it at a few aspect ratios (3:2, 16:9, 1:1, a tall 9:16) and a few angles. It takes a few minutes to write and catches the exact bug this post is about.</p>
<h2>What I'd do next</h2>
<p>If you're building this yourself, the next things worth adding are:</p>
<ul>
<li>A <strong>template</strong> type that stores text, angle, opacity and scale, so a user can save the setup once and apply it to every batch. Everything above is already driven by <code>TileStyle</code>, so that's a <code>Codable</code> away.</li>
<li>A <strong>density</strong> control. Gaps that are too tight make the photo hard to look at; too wide and a determined person can crop between the lines. A gap of about one text width and a row step of about two text heights is a reasonable default.</li>
<li><strong>Colour-aware contrast.</strong> Sample the average brightness under each tile and flip between white and dark text. It's more work, but it makes the mark readable on every photo in a mixed batch.</li>
</ul>
<p>If you try any of this, I'd like to hear where it breaks. Real photos find the edge cases quickly.</p>
<hr />
<p><em>I'm an independent developer and I work on BatchMark (灵感图像大师 in Chinese), an iPhone and iPad app for batch watermarks, art frames and Color Walk colour cards. Everything it does runs on the device. If you're curious, here's the <a href="https://batchmark.dispatchcat.com/en/features/batch-watermark/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=round1">batch watermark page</a>, and it's on the <a href="https://apps.apple.com/us/app/batchmark-photo-watermark/id6759683285">App Store</a>.</em></p>
]]></content:encoded></item></channel></rss>