<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Photography on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/photography/</link><description>Recent content in Photography on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 12 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/photography/index.xml" rel="self" type="application/rss+xml"/><item><title>Why my photo app won't build for Windows</title><link>https://phpboyscout.uk/why-my-photo-app-wont-build-for-windows/</link><pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/why-my-photo-app-wont-build-for-windows/</guid><description>&lt;img src="https://phpboyscout.uk/why-my-photo-app-wont-build-for-windows/cover-why-my-photo-app-wont-build-for-windows.png" alt="Featured image of post Why my photo app won't build for Windows" /&gt;&lt;p&gt;I was wiring up the release build for krites, the wedding-photo culler I&amp;rsquo;ve been &lt;a class="link" href="https://phpboyscout.uk/i-built-my-wife-a-judge/" &gt;building for my wife&lt;/a&gt;, and I went to add Windows to the list of targets. It felt like the responsible thing. You write software, you ship it for the three big desktops, that&amp;rsquo;s just tidy. So I added &lt;code&gt;windows&lt;/code&gt; to the build config, kicked off a cross-compile from my Linux box, and watched it fall over before it had really started.&lt;/p&gt;
&lt;p&gt;Now, the sensible engineer&amp;rsquo;s instinct at that point is to roll up the sleeves and fix it. Mine was too, for about five minutes&amp;hellip; then I read the actual error, thought about who was ever going to run this thing, and did the opposite. I deleted Windows and moved on.&lt;/p&gt;
&lt;h2 id="the-binary-with-no-c-in-it"&gt;The binary with no C in it
&lt;/h2&gt;&lt;p&gt;To explain why the build died, I have to back up to a decision that felt clever at the time.&lt;/p&gt;
&lt;p&gt;krites does some machine learning. Not for the culling itself, which is &lt;a class="link" href="https://phpboyscout.uk/no-ai-in-my-photo-culler/" &gt;deliberately model-free arithmetic&lt;/a&gt;, but for the finer judgements: is a subject mid-blink, are they looking at the camera. Those run on ONNX Runtime, Microsoft&amp;rsquo;s inference engine, which is a big lump of C++ that lives in a shared library.&lt;/p&gt;
&lt;p&gt;The usual way to call a C library from Go is CGO. It works, but it comes with a tax: the moment you switch CGO on, building your program means having a C compiler and the right headers for every platform you target. Cross-compiling from one machine to all the others, which is the whole convenience of Go, stops being free. You end up maintaining a little zoo of toolchains just to produce binaries.&lt;/p&gt;
&lt;p&gt;So I went the other way. There&amp;rsquo;s a lovely library called &lt;a class="link" href="https://github.com/ebitengine/purego" target="_blank" rel="noopener"
 &gt;purego&lt;/a&gt; that lets you call into a C shared library with CGO switched off entirely. It does the dynamic loading itself, in pure Go, and hands you back function pointers. The ONNX binding krites uses, &lt;code&gt;onnxruntime-purego&lt;/code&gt;, is built on it. The upshot is a binary compiled with &lt;code&gt;CGO_ENABLED=0&lt;/code&gt;: no C compiler in the build, no headers, cross-compile to anything from my laptop. The provisioner that fetches the runtime library is &lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/a02f678/pkg/onnxruntime/onnxruntime.go#L1-L45" target="_blank" rel="noopener"
 &gt;ordinary Go&lt;/a&gt;, &lt;code&gt;net/http&lt;/code&gt; and &lt;code&gt;archive/tar&lt;/code&gt;, nothing exotic.&lt;/p&gt;
&lt;p&gt;Pure Go, no CGO, builds everywhere. You can probably see where this is going&amp;hellip;&lt;/p&gt;
&lt;h2 id="the-trick-that-doesnt-cross-the-channel"&gt;The trick that doesn&amp;rsquo;t cross the Channel
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit I hadn&amp;rsquo;t thought hard enough about. &lt;em&gt;How&lt;/em&gt; does purego load a shared library without CGO? It calls &lt;code&gt;dlopen&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;dlopen&lt;/code&gt; is a Unix system call. It&amp;rsquo;s how a running program reaches out and pulls in a &lt;code&gt;.so&lt;/code&gt; or a &lt;code&gt;.dylib&lt;/code&gt; at runtime, the thing CGO normally does for you behind the scenes. purego just does it directly. And that&amp;rsquo;s the whole trick: no C compiler needed, because the loading happens through a system call that&amp;rsquo;s already sitting there in the OS.&lt;/p&gt;
&lt;p&gt;Except it isn&amp;rsquo;t there on Windows. Windows has its own mechanism for the same job, &lt;code&gt;LoadLibrary&lt;/code&gt;, with a completely different shape. So purego&amp;rsquo;s &lt;code&gt;dlopen&lt;/code&gt; and the flags that go with it (&lt;code&gt;RTLD_NOW&lt;/code&gt;, &lt;code&gt;RTLD_GLOBAL&lt;/code&gt;, if you&amp;rsquo;ve ever squinted at a Unix man page) simply don&amp;rsquo;t exist when you compile for Windows. They&amp;rsquo;re guarded behind a build constraint that reads, in effect, &amp;ldquo;Linux or macOS or the other BSDs, and nothing else&amp;rdquo;. Target Windows and those symbols vanish. The binding calls a function that, on that platform, was never compiled in.&lt;/p&gt;
&lt;p&gt;My favourite detail is that the binding &lt;em&gt;looks&lt;/em&gt; like it supports Windows right up until the moment it can&amp;rsquo;t. Its library-name helper has a branch for it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;switch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GOOS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;darwin&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;libonnxruntime.dylib&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;linux&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;libonnxruntime.so&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;windows&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;onnxruntime.dll&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It knows the DLL is called &lt;code&gt;onnxruntime.dll&lt;/code&gt;. It&amp;rsquo;s dressed for the party. Then two lines later it goes to actually open the thing with &lt;code&gt;dlopen&lt;/code&gt;, the linker looks for a symbol that isn&amp;rsquo;t there for this target, and the whole build stops. The Windows case is code that can never run, because it can never compile.&lt;/p&gt;
&lt;p&gt;So this was never a bug waiting for me to be clever enough to fix. A CGO-free binding buys you a great deal, but it buys it &lt;em&gt;by being Unix&lt;/em&gt;. The portability I was so chuffed with was POSIX portability, and Windows was never inside the fence. I&amp;rsquo;d picked the option that looked like it built everywhere and it still had a border on it.&lt;/p&gt;
&lt;h2 id="knowing-where-the-software-actually-lives"&gt;Knowing where the software actually lives
&lt;/h2&gt;&lt;p&gt;Which left me with the interesting question, and it wasn&amp;rsquo;t &amp;ldquo;how do I make Windows work&amp;rdquo;. It was &amp;ldquo;does anyone need it to&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;krites is for Hailey, my wife. She&amp;rsquo;s a photographer, and she works on a Mac, because that&amp;rsquo;s what photographers do. The whole plan for the ML side leans into that: the fast path is Apple&amp;rsquo;s own CoreML running on the M-series chip, the dylib bundled right beside the app. macOS isn&amp;rsquo;t a platform I tolerate here, it&amp;rsquo;s the one I&amp;rsquo;m building &lt;em&gt;for&lt;/em&gt;. Linux is the other target, and it earns its place for a different reason, it&amp;rsquo;s where the CI runs and where a headless batch job would live. Neither of those is Windows. Windows was on the list purely because three feels like a rounder number than two.&lt;/p&gt;
&lt;p&gt;So the fix was a diff that removed a line. The &lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/a02f678/.goreleaser.yaml#L9-L22" target="_blank" rel="noopener"
 &gt;release config&lt;/a&gt; now lists &lt;code&gt;linux&lt;/code&gt; and &lt;code&gt;darwin&lt;/code&gt; and stops there, with a comment for the next person (me, in six months, having forgotten all of this) explaining why the obvious third entry isn&amp;rsquo;t obvious at all.&lt;/p&gt;
&lt;p&gt;I could have chased it. There are ways: shim the loader, find a binding that speaks &lt;code&gt;LoadLibrary&lt;/code&gt;, turn CGO back on for the Windows build alone and hand-feed it a toolchain. Every one of them is real work in service of a platform my one and only user will never open the app on. That&amp;rsquo;s effort spent making a number rounder.&lt;/p&gt;
&lt;p&gt;A build target isn&amp;rsquo;t a box you tick to look thorough. It&amp;rsquo;s a claim about where your software actually lives, and krites lives on a photographer&amp;rsquo;s Mac. Once I&amp;rsquo;d said that out loud, the Windows build stopped being a problem to solve. It was a line I got to delete.&lt;/p&gt;</description></item><item><title>You can't reshoot a wedding</title><link>https://phpboyscout.uk/you-cant-reshoot-a-wedding/</link><pubDate>Wed, 08 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/you-cant-reshoot-a-wedding/</guid><description>&lt;img src="https://phpboyscout.uk/you-cant-reshoot-a-wedding/cover-you-cant-reshoot-a-wedding.png" alt="Featured image of post You can't reshoot a wedding" /&gt;&lt;p&gt;A wedding happens once. The first kiss, the confetti, the father of the bride going to pieces halfway through his speech, all of it unrepeatable. If the photographer loses the RAW files from the day, there is no second take. You can apologise, you can offer the money back, but you cannot give anyone the morning again.&lt;/p&gt;
&lt;p&gt;So for a tool that sits between a photographer and four thousand irreplaceable files, &amp;ldquo;don&amp;rsquo;t damage the originals&amp;rdquo; is not a feature on a list. It is the entire job. Everything clever &lt;a class="link" href="https://phpboyscout.uk/i-built-my-wife-a-judge/" &gt;krites&lt;/a&gt; does, the culling, the sorting, the edits, is worthless the instant it harms a single frame it was handed.&lt;/p&gt;
&lt;p&gt;Most software handles this with care. krites handles it by making harm impossible.&lt;/p&gt;
&lt;h2 id="careful-isnt-the-same-as-locked-out"&gt;Careful isn&amp;rsquo;t the same as locked out
&lt;/h2&gt;&lt;p&gt;There is a real difference between a tool that &lt;em&gt;tries&lt;/em&gt; not to touch your originals and one that &lt;em&gt;can&amp;rsquo;t&lt;/em&gt;. The first is an intention: a line in the contributing guide, a &amp;ldquo;we never modify originals&amp;rdquo; promise, a reviewer remembering to check. Intentions hold right up until the afternoon someone adds a feature in a hurry and forgets. The second is a property of the architecture, and it holds whether or not anyone is paying attention.&lt;/p&gt;
&lt;p&gt;krites is built to be the second kind. The guarantee isn&amp;rsquo;t that I&amp;rsquo;m careful. It&amp;rsquo;s that there is no code path that writes to an original, by construction.&lt;/p&gt;
&lt;h2 id="originals-go-in-and-only-ever-come-out-as-copies"&gt;Originals go in, and only ever come out as copies
&lt;/h2&gt;&lt;p&gt;A shoot in krites is just a folder. The imported frames live in &lt;code&gt;originals/&lt;/code&gt;, and they are treated as read-only inputs, never written after import. Every decision krites makes lands in a hidden &lt;code&gt;.krites/&lt;/code&gt; sidecar tree beside them. The only place new pixels are ever produced is a separate &lt;code&gt;export/&lt;/code&gt; directory, written when you explicitly export. (The layout of all this is the shoot workspace, spec &lt;code&gt;0001&lt;/code&gt; §3.)&lt;/p&gt;
&lt;p&gt;The code says the same thing the design does. The package that owns the workspace opens with it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Package shoot is the non-destructive shoot workspace: a directory of original&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// frames plus a .krites sidecar tree holding the manifest and the culling&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// verdicts. Originals are inputs, never written after ingest.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And the only function that opens an original opens it one way:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// OpenFrame opens an original frame read-only by its manifest-relative path.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;There is no open-an-original-for-writing function anywhere, because nothing legitimately needs one (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/shoot/shoot.go#L1-L4" target="_blank" rel="noopener"
 &gt;&lt;code&gt;shoot.go&lt;/code&gt;&lt;/a&gt;, &lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/shoot/shoot.go#L171-L179" target="_blank" rel="noopener"
 &gt;&lt;code&gt;OpenFrame&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h2 id="a-verdict-is-a-record-not-a-scalpel"&gt;A verdict is a record, not a scalpel
&lt;/h2&gt;&lt;p&gt;This works because of one decision that runs through everything: a verdict, a crop, a straighten, a colour grade, is a &lt;em&gt;record&lt;/em&gt;, not a change to a pixel. Keep, maybe, reject is a line in &lt;code&gt;verdicts.yaml&lt;/code&gt;. An edit is an instruction in &lt;code&gt;edits/&lt;/code&gt;. Nothing has been done to the photo; krites has just written down what it &lt;em&gt;would&lt;/em&gt; do.&lt;/p&gt;
&lt;p&gt;The only command in the whole tool that produces output pixels is &lt;code&gt;export&lt;/code&gt;, and it renders those instructions into the &lt;code&gt;export/&lt;/code&gt; directory, never back over an original. Even the function that copies a chosen frame out says so in as many words:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// CopyToExport copies an original frame verbatim into the shoot&amp;#39;s export/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// directory. It is the only writer outside .krites and never touches the&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// originals.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the load-bearing contract the spec calls out by name (&lt;code&gt;R-ND-1&lt;/code&gt; and &lt;code&gt;R-ND-2&lt;/code&gt;): originals are never modified, and the only thing that writes new pixels is export (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/docs/development/specs/0002-interface-contracts.md#L80-L94" target="_blank" rel="noopener"
 &gt;the non-destructive guarantee&lt;/a&gt;, &lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/shoot/shoot.go#L181-L185" target="_blank" rel="noopener"
 &gt;&lt;code&gt;CopyToExport&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Two things fall out of it for free. Because every decision is a record, undo is total: a &amp;ldquo;reset&amp;rdquo; returns a frame to exactly its just-imported state, because nothing was ever actually changed (&lt;code&gt;R-ND-3&lt;/code&gt;). And because the analysis scores and the browsing previews are only caches, you can delete the whole &lt;code&gt;.krites/analysis/&lt;/code&gt; and &lt;code&gt;.krites/previews/&lt;/code&gt; and rebuild them with no loss of a single decision (&lt;code&gt;R-ND-4&lt;/code&gt;). The durable things are the verdicts and the edits; everything else is disposable.&lt;/p&gt;
&lt;h2 id="even-delete-doesnt-delete"&gt;Even &amp;ldquo;delete&amp;rdquo; doesn&amp;rsquo;t delete
&lt;/h2&gt;&lt;p&gt;The most frightening verb in any library tool is &lt;em&gt;delete&lt;/em&gt;. You hover over the button&amp;hellip; you think about what&amp;rsquo;s behind it.&lt;/p&gt;
&lt;p&gt;In krites, removing a shoot forgets it. It drops the shoot from the studio&amp;rsquo;s list of folders it knows about, and it does not touch a single file on disk:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Remove forgets a shoot from the library; it never touches the shoot&amp;#39;s files.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The HTTP endpoint behind the studio&amp;rsquo;s delete button is held to the same rule: it forgets the shoot, it never deletes files (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/studio/library.go#L133-L141" target="_blank" rel="noopener"
 &gt;&lt;code&gt;Remove&lt;/code&gt;&lt;/a&gt;). The scariest control in the interface has been defanged. The worst it can do is make krites stop paying attention to a folder that is still, untouched, exactly where you left it.&lt;/p&gt;
&lt;h2 id="where-the-rule-actually-lives"&gt;Where the rule actually lives
&lt;/h2&gt;&lt;p&gt;The studio&amp;rsquo;s contract states it outright: non-destructive &lt;em&gt;by construction&lt;/em&gt;, no endpoint mutates an original. That phrase is the whole idea in three words. The promise isn&amp;rsquo;t kept by me being disciplined every time I add a feature. It&amp;rsquo;s kept by how the code is built: originals open read-only, exactly one writer lives outside the sidecar tree and it writes to &lt;code&gt;export/&lt;/code&gt;, and there is no other door.&lt;/p&gt;
&lt;p&gt;When the cost of a mistake is total and unrecoverable, you don&amp;rsquo;t build a tool that avoids the mistake. You build one where the mistake can&amp;rsquo;t be made.&lt;/p&gt;
&lt;p&gt;The motto on this blog is to leave the codebase better than you found it. Here the rule is narrower and stricter, and a wedding depends on it: leave the originals &lt;em&gt;exactly&lt;/em&gt; as you found them. Every byte, every frame, every time.&lt;/p&gt;
&lt;h2 id="the-folder-it-cant-write-to"&gt;The folder it can&amp;rsquo;t write to
&lt;/h2&gt;&lt;p&gt;Hailey is never going to read a requirement labelled &lt;code&gt;R-API-2&lt;/code&gt;. She doesn&amp;rsquo;t need to. She needs to know that the tool sorting four thousand photos of someone&amp;rsquo;s most important day cannot, under any circumstances, be the thing that loses them. So I made sure it can&amp;rsquo;t. There&amp;rsquo;s one folder krites is simply never allowed to write to. The rest of the tool I&amp;rsquo;ll argue about all day; not that one.&lt;/p&gt;</description></item><item><title>There's no AI in my photo culler</title><link>https://phpboyscout.uk/no-ai-in-my-photo-culler/</link><pubDate>Wed, 01 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/no-ai-in-my-photo-culler/</guid><description>&lt;img src="https://phpboyscout.uk/no-ai-in-my-photo-culler/cover-no-ai-in-my-photo-culler.png" alt="Featured image of post There's no AI in my photo culler" /&gt;&lt;p&gt;Before a wedding photographer can edit a single frame, there&amp;rsquo;s the cull: sitting down with three or four thousand photos from the day and deciding which are even worth keeping. The blurry ones, the ones where the flash fired into a mirror, the same moment shot eight times in a burst where only one frame is sharp. It&amp;rsquo;s mechanical, it&amp;rsquo;s exhausting, and it&amp;rsquo;s the first job krites does for Hailey.&lt;/p&gt;
&lt;p&gt;Every culling tool I looked at before building it leads with the same word. AI. AI culling, AI selects, trained on millions of weddings. So when I sat down to write krites&amp;rsquo; first pass, I assumed I&amp;rsquo;d be wiring up a model too. For the part that does the most work, it turns out, I didn&amp;rsquo;t need one.&lt;/p&gt;
&lt;p&gt;The shipped culler doesn&amp;rsquo;t load a single weight. It&amp;rsquo;s arithmetic, the sort a calculator could do if you were patient enough, and that&amp;rsquo;s a deliberate choice rather than a corner I cut. Here&amp;rsquo;s what&amp;rsquo;s actually under it.&lt;/p&gt;
&lt;h2 id="blur-is-the-variance-of-a-laplacian"&gt;Blur is the variance of a Laplacian
&lt;/h2&gt;&lt;p&gt;The first question for any frame is whether it&amp;rsquo;s in focus. You can answer it without knowing anything about what&amp;rsquo;s in the photo.&lt;/p&gt;
&lt;p&gt;A Laplacian is an edge detector. Run it over an image and it lights up wherever the brightness changes sharply, the crisp boundary between a dark suit and a white shirt, the line of an eyelash. A photo in focus is full of those sharp transitions; a soft or motion-blurred one has smeared them all into gentle gradients. So if you measure how much the edge response &lt;em&gt;varies&lt;/em&gt; across the frame, a sharp photo gives you a big spread of values and a blurry one gives you a flat, lifeless number. That single number is the focus score.&lt;/p&gt;
&lt;p&gt;In krites it&amp;rsquo;s a 3×3 kernel over the frame&amp;rsquo;s luma (the brightness channel, Rec. 601 weights), and the score is the variance of the response:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;lap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;luma&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;lapCenter&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Sum the responses, sum their squares, and the variance falls out as &lt;code&gt;sumSq/n - mean*mean&lt;/code&gt;. No training data, no inference, and the same pixels always give the same answer. (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/analyze/quality/quality.go#L89-L118" target="_blank" rel="noopener"
 &gt;&lt;code&gt;quality.go&lt;/code&gt;&lt;/a&gt;.)&lt;/p&gt;
&lt;h2 id="exposure-is-a-histogram"&gt;Exposure is a histogram
&lt;/h2&gt;&lt;p&gt;The second question is whether the exposure is salvageable. If a third of the frame is pure white, the highlights are blown and there&amp;rsquo;s no detail to bring back; if it&amp;rsquo;s mostly pure black, the shadows are crushed the same way.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s just counting. Walk the luma plane once, tally how many pixels sit at or above a near-white threshold and how many at or below a near-black one, divide by the total, and you&amp;rsquo;ve got two fractions: the blown-highlight proportion and the crushed-shadow proportion. A photographer cares about those two numbers directly, and a &lt;code&gt;for&lt;/code&gt; loop produces them (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/analyze/quality/quality.go#L120-L140" target="_blank" rel="noopener"
 &gt;&lt;code&gt;quality.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h2 id="two-photos-are-the-same-when-sixty-four-bits-agree"&gt;Two photos are the same when sixty-four bits agree
&lt;/h2&gt;&lt;p&gt;Then there are the bursts. A photographer holds the shutter through the first kiss and gets twelve nearly-identical frames; you want the sharpest one and the rest out of the way. To do that the tool has to know which frames are &amp;ldquo;the same shot&amp;rdquo;, and again you don&amp;rsquo;t need to understand the photo to tell.&lt;/p&gt;
&lt;p&gt;The trick is a perceptual hash, a difference hash to be exact. Shrink the image right down to a nine-by-eight grey thumbnail, then for each row note simply whether each cell is brighter than the one to its right. That&amp;rsquo;s sixty-four yes/no comparisons, packed into a sixty-four-bit number, a fingerprint of the picture&amp;rsquo;s broad light-and-dark structure that survives a resize, a small reframe or a touch of noise:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;grey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;hashW&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;grey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;hashW&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bit&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two fingerprints are compared by counting the bits that differ between them, the Hamming distance, which on a 64-bit integer is one CPU instruction (&lt;code&gt;bits.OnesCount64&lt;/code&gt;). A small distance means the frames look alike. krites only clusters &lt;em&gt;consecutive&lt;/em&gt; frames within that distance, so a run of similar shots merges into a burst but two unrelated photos that happen to rhyme don&amp;rsquo;t (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/analyze/dedup/dedup.go#L37-L89" target="_blank" rel="noopener"
 &gt;&lt;code&gt;dedup.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Best-of-burst is then the dullest line of code in the project: keep the sharpest frame in the cluster, demote the others from &lt;em&gt;keep&lt;/em&gt; to &lt;em&gt;maybe&lt;/em&gt;, and write down why.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reasons&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Reasons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;near-duplicate of &amp;#34;&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;bestFrame&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;&amp;#34; (kept the sharper frame)&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="signals-in-a-verdict-out"&gt;Signals in, a verdict out
&lt;/h2&gt;&lt;p&gt;None of those measurements decide anything on their own. A focus score of 50 is rejectable on one shoot and fine on another, because the numbers scale with resolution and content. So the signals feed a &lt;em&gt;profile&lt;/em&gt;, a small set of thresholds, and the profile turns them into a ruling: below the hard focus gate it&amp;rsquo;s a reject, below a softer floor it&amp;rsquo;s a maybe, blown past the exposure gates it&amp;rsquo;s a reject, otherwise it&amp;rsquo;s a keep. Every verdict carries its reasons in plain words, &amp;ldquo;out of focus (sharpness 32 below 50)&amp;rdquo;, because krites proposes and the human disposes (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/cull/cull.go#L71-L108" target="_blank" rel="noopener"
 &gt;&lt;code&gt;cull.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The seed thresholds for a wedding are just a starting point, written to config on &lt;code&gt;krites init&lt;/code&gt; and tuned from there:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedMinSharpness&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// below this: rejected as out of focus&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedSoftSharpness&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// below this (but &amp;gt;= min): demoted to maybe&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedMaxHighlights&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedMaxShadows&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.30&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;seedDedupDistance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The thresholds are the whole point of keeping them visible. &amp;ldquo;Suitable for a wedding album&amp;rdquo; is Hailey&amp;rsquo;s definition, not mine and not a model&amp;rsquo;s, and a number in a config file is something she can move (&lt;a class="link" href="https://gitlab.com/phpboyscout/krites/-/blob/fe863ae/pkg/cull/profile.go#L9-L29" target="_blank" rel="noopener"
 &gt;&lt;code&gt;profile.go&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;h2 id="where-the-models-do-belong"&gt;Where the models do belong
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;m not claiming AI has no place in this. Some of what a wedding photographer culls on genuinely needs a model: is this person mid-blink, is anyone actually looking at the camera, is the composition any good. Those are coming, and they&amp;rsquo;ll be model-backed when they do. The deliberate bit is that they sit &lt;em&gt;outside&lt;/em&gt; this deterministic core, behind an interface, opt-in. The maths that does the heavy lifting of the first pass never imports a model.&lt;/p&gt;
&lt;p&gt;That separation buys three things you lose the moment a neural net touches the hot path. It&amp;rsquo;s reproducible: the same frames in the same order always cull the same way, so a verdict is debuggable and a regression is catchable. It&amp;rsquo;s quick enough to run over four thousand frames on a laptop with no GPU. And it stays honest about what it knows, because a threshold you can read is a threshold you can argue with, which a confidence score from a black box never quite is.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;AI culling&amp;rdquo; makes for a better headline. But blur really is just a number, a duplicate really is just sixty-four bits, and the grim, mechanical first pass that stands between a photographer and their best photos comes down to arithmetic.&lt;/p&gt;</description></item><item><title>I built my wife a judge</title><link>https://phpboyscout.uk/i-built-my-wife-a-judge/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/i-built-my-wife-a-judge/</guid><description>&lt;img src="https://phpboyscout.uk/i-built-my-wife-a-judge/cover-i-built-my-wife-a-judge.png" alt="Featured image of post I built my wife a judge" /&gt;&lt;p&gt;The morning after Hailey shot a wedding, we were both wrecked, lying in bed and not really moving, and she said she might just pay for &lt;a class="link" href="https://aftershoot.com" target="_blank" rel="noopener"
 &gt;Aftershoot&lt;/a&gt; to get through the cull. And my ears pricked up.&lt;/p&gt;
&lt;p&gt;She&amp;rsquo;d been on her feet for the best part of fourteen hours the day before. She had four thousand photos waiting for her. And I, lying there next to her, realised that for the first time the thing I do in my spare time, the obsession that mostly just pays my half of the bills, might actually be of some use to her.&lt;/p&gt;
&lt;h2 id="four-thousand-photos-and-the-weeks-that-follow"&gt;Four thousand photos and the weeks that follow
&lt;/h2&gt;&lt;p&gt;A bit of background. My wife is a photographer. Not full-time yet, but that&amp;rsquo;s the plan, and she&amp;rsquo;s been chipping away at it around life and the bills ever since she set up &lt;a class="link" href="https://www.instagram.com/echos_photographyuk/" target="_blank" rel="noopener"
 &gt;Echos Photography&lt;/a&gt; a couple of years ago. Her real love is landscape and animals, the patient stuff, but every so often she&amp;rsquo;ll take on a wedding or some portraits, and last weekend she was the photographer for a family friend&amp;rsquo;s big day. A lovely affair. She shot it; I flew the drone for a bit of aerial photography and video, which mostly means I got to stand in a field feeling useful while she did the actual work.&lt;/p&gt;
&lt;p&gt;Then it ends, and the real job starts. Four thousand frames, and before a single one gets edited somebody has to sit and look at every one and decide which are even worth keeping. The blinks, the soft ones, the same moment fired off eight times in a burst where only one frame is sharp. Done by hand, in Lightroom, one at a time, that can take her weeks. Months, sometimes, depending on what else life is throwing at her. So when she said she&amp;rsquo;d pay a subscription to make the first pass go away, I understood completely. It&amp;rsquo;s a genuinely grim job.&lt;/p&gt;
&lt;p&gt;I just thought I could do better&amp;hellip; for her, specifically.&lt;/p&gt;
&lt;h2 id="why-my-ears-pricked-up"&gt;Why my ears pricked up
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;ve been building developer tools for a while now. &lt;a class="link" href="https://gtb.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;go-tool-base&lt;/a&gt; has reached the point where it&amp;rsquo;s actually pleasant to build things on, and the work I&amp;rsquo;d been doing on keryx, a little studio you drive from a browser rather than a command line, had shown me the shape of something a non-developer could use without ever touching a terminal. The pieces were sitting there&amp;hellip; they just needed pointing at a different problem.&lt;/p&gt;
&lt;p&gt;So I built her a judge. That&amp;rsquo;s what the name means, as it happens. krites is Ancient Greek for &amp;ldquo;the judge&amp;rdquo;, and that&amp;rsquo;s the whole product in one word: the thing that looks at every frame and rules on it, keep, maybe, reject, with its reasons, so the human doesn&amp;rsquo;t have to do the first exhausting pass by hand. It judges; she still decides. It proposes, she disposes. The tool never gets the last word, and it was never meant to.&lt;/p&gt;
&lt;h2 id="a-tool-thats-only-trying-to-please-one-person"&gt;A tool that&amp;rsquo;s only trying to please one person
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the thing an off-the-shelf product can never quite be: krites is opinionated entirely in Hailey&amp;rsquo;s favour. It&amp;rsquo;s built around her workflow, her kit, her idea of what makes a frame worth keeping, and it&amp;rsquo;s designed so that she can use it with no setup and no technical knowledge whatsoever. The day it asks her to edit a config file is the day I&amp;rsquo;ve failed.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve also kept it deliberately dim. There&amp;rsquo;s very little machine learning in it, and that&amp;rsquo;s a choice rather than a limitation. Most of the first pass, deciding whether a photo is in focus or hopelessly blown out, whether it&amp;rsquo;s the eighth near-identical frame in a burst, turns out to be arithmetic, and arithmetic is simple, fast and predictable in a way a model never quite is. I&amp;rsquo;ll reach for ML where there&amp;rsquo;s genuinely no other way (telling whether someone&amp;rsquo;s mid-blink is the obvious one), and when I do it&amp;rsquo;ll lean on the Apple hardware she already owns, with a deliberate gap left so it can grow onto other machines later. But the rule is: no cleverness she doesn&amp;rsquo;t need.&lt;/p&gt;
&lt;p&gt;And it all runs on her own machine, on purpose. No subscription, which matters when the photography isn&amp;rsquo;t paying for itself yet. No reliance on a connection, which matters because we&amp;rsquo;re hoping to spend a good chunk of the near future on the road (there&amp;rsquo;s a whole vanlife plan brewing, with a &lt;a class="link" href="https://shutter-and-stove-ec1680.gitlab.io" target="_blank" rel="noopener"
 &gt;work-in-progress demo site&lt;/a&gt; slowly coming together), and a campsite in the middle of nowhere is exactly where you want to be culling a backlog and exactly where you won&amp;rsquo;t have signal. And nothing leaves the laptop, which matters most of all, because these are other people&amp;rsquo;s weddings. Their photos shouldn&amp;rsquo;t have to go and live on somebody&amp;rsquo;s cloud to be useful to the woman they hired.&lt;/p&gt;
&lt;h2 id="why-not-just-pay-for-the-thing-that-exists"&gt;Why not just pay for the thing that exists
&lt;/h2&gt;&lt;p&gt;Aftershoot is good. Genuinely good. The easy version of this post is the one where I sneer at the incumbent, and I won&amp;rsquo;t: they&amp;rsquo;ve built something clever, and plenty of photographers are well served by it.&lt;/p&gt;
&lt;p&gt;But it&amp;rsquo;s a product, and a product is a compromise by definition. It&amp;rsquo;s built for the average of everyone, and it can only ever evolve in the directions a company decides are profitable. Hailey isn&amp;rsquo;t the average of everyone. Something I build is hers, and it can grow in any direction she can imagine, including the unprofitable, niche, only-makes-sense-for-one-photographer directions that no company would ever green-light. That&amp;rsquo;s not really a technical argument. It&amp;rsquo;s closer to pride. I&amp;rsquo;d rather build my wife the tool than rent her someone else&amp;rsquo;s.&lt;/p&gt;
&lt;h2 id="early-days-and-the-people-who-made-it-possible"&gt;Early days, and the people who made it possible
&lt;/h2&gt;&lt;p&gt;A word on where this actually is, though. What&amp;rsquo;s shipped today is the cull, the first pass I&amp;rsquo;ve described. The straightening, the cropping, the colour work, the little retouches that eat her evenings, the part where it learns her taste over time, that&amp;rsquo;s all roadmap, and I&amp;rsquo;ll write about each piece as it lands. krites is at the very start of its life.&lt;/p&gt;
&lt;p&gt;None of it would exist at all without three things, and only one of them is mine. go-tool-base finally being solid enough to stand on. Being able to build, with a lot of AI assistance, far faster than I ever could have managed alone, fast enough that &amp;ldquo;I could probably build that&amp;rdquo; became &amp;ldquo;I have built that&amp;rdquo; inside a weekend. And Hailey, whose infinite patience and gracious kindness in indulging my passions is the only reason any of my obsessions ever get the room to become something.&lt;/p&gt;
&lt;p&gt;Which is the part I keep coming back to. I&amp;rsquo;ve &lt;a class="link" href="https://phpboyscout.uk/why-i-still-write-code/" &gt;written before&lt;/a&gt; about the cost of the compulsion to build, the evenings it takes and the people it takes them from. krites is the same compulsion, for once, pointed the other way. The thing that usually pulls me away from her, aimed squarely at giving her her evenings back. I don&amp;rsquo;t think I&amp;rsquo;ve enjoyed building anything more.&lt;/p&gt;
&lt;p&gt;She still gets the final say on every photo. Obviously. She&amp;rsquo;s the judge that matters. I just built her a faster one to argue with.&lt;/p&gt;</description></item></channel></rss>