I Trained a Neural Network to Recognise My Neighbour's Cat (So It Could Get Squirted With Water)

I have four cats. Boots is not one of them.
Boots is the neighbour's cat — solid black, bright green eyes, an inquisitive expression that does not adequately convey his true purpose in life, which is eating other cats' food. He discovered that my cat flap is unlocked and that my kitchen contains a full bowl of kibble. For a while, I tried simply closing the flap. Boots, apparently, can open it from outside. He has worked this out. He is proud of this.
The situation called for a deterrent. Not harm — a deterrent. Something that would make the kitchen feel unwelcoming to Boots specifically, whilst leaving my four cats (Chi, Tau, Kappa, and Mu) entirely undisturbed. A water mist seemed right. But you can't just spray every cat that comes in — Kappa in particular has very strong opinions about getting wet, and he is technically blameless.
What I needed was a camera above the food bowls that could tell the difference between Boots and everyone else. A binary classifier. A targeted, automated, cloud-connected cat bouncer.

The problem statement
From a machine learning perspective, this is almost offensively simple. I have one target class (Boots) and a collection of non-target classes (Chi, Tau, Kappa, Mu, and occasionally Wolf — a stuffed toy I keep around for testing, since he is easier to pose than any of the actual cats). I want the system to fire a water mist when it sees Boots and only when it sees Boots, with a confidence threshold high enough that the cost of a false positive (squirting Kappa, explaining this to Kappa, living with the consequences) outweighs the cost of a false negative (Boots gets one free meal).
I set the threshold at 90%. If SageMaker is not 90% certain it's looking at Boots, the atomiser does nothing.
Collecting training data
The camera is an OV5640 (5MP) mounted on an ESP32-S3 above the food bowls. Every time the PIR motion sensor fires, the device captures a frame, uploads it to S3 via API Gateway, and calls a Lambda for inference. During the labelling phase, I reviewed those images and tagged them in DynamoDB with the cat's name. This is the training corpus.
The problem with a naturally collected dataset is class imbalance. Boots is an intruder. My cats live here. Over time, the corpus accumulated far more NotBoots frames than Boots frames. Feed that imbalance directly to a classifier and it learns the easy shortcut: predict NotBoots always, achieve 80% accuracy, provide no useful signal at all.
The fix is undersampling. The data preparation Lambda caps the NotBoots class at a configurable ceiling — 600 images, shuffled to ensure a representative spread across all four cats — before passing the dataset to training:
const notbootsImages = shuffle(allNotboots).slice(0, MAX_NOTBOOTS_SAMPLES);
const bootsImages = allBoots; // keep all Boots images — we want every example we have
This produces a balanced training set regardless of how many times Tau has wandered past the camera looking regal.
The model
Transfer learning on MobileNetV2, pre-trained on ImageNet, fine-tuned on the labelled corpus. The model takes 224×224 RGB images and outputs two probabilities: [p_boots, p_notboots]. SageMaker handles training as a managed job triggered by a Lambda; the resulting model is deployed to a serverless inference endpoint that scales to zero between activations.
The one unusual detail in the serving configuration: TensorFlow Serving can't decode raw JPEG bytes by default. Rather than convert all images to tensors at upload time, the JPEG decoder is baked directly into the model's serving signature:
@tf.function(input_signature=[
tf.TensorSpec(shape=[None], dtype=tf.string, name="image_bytes")
])
def serve_jpeg(image_bytes):
def decode_one(img_bytes):
img = tf.image.decode_jpeg(img_bytes, channels=3)
img = tf.image.resize(img, [224, 224])
return tf.cast(img, tf.float32) / 255.0
return tf.map_fn(decode_one, image_bytes, fn_output_signature=tf.float32)
The device sends raw JPEG bytes. The model decodes them itself. No preprocessing pipeline on the embedded side.
A second opinion
A 90% confidence threshold means the model must be very sure before it fires. That's conservative by design. But it raised a question: what does "90% confident" actually look like for a case the model hasn't seen before — Boots at an unusual angle, in low light, half-occluded by a bowl? A single model giving a high score to something it shouldn't is called overconfidence. It happens.
To guard against this, the inference Lambda runs a second model in parallel: Claude Haiku with vision. Both run concurrently via Promise.all:
const [sagemakerResult, claudeResult] = await Promise.all([
invokeSageMaker(imageBuffer),
invokeClaudeVision(imageBuffer),
]);
Claude sees the same image and responds with a structured assessment:
{
"cat": "Boots",
"confidence": "high",
"reasoning": "Solid black coat, bright green eyes, compact frame"
}
If SageMaker says 93% Boots but Claude says "Unknown", that discrepancy gets logged. The system doesn't wait for Claude before making its deterrent decision — SageMaker is faster and its confidence threshold is the gate — but the parallel inference creates an audit trail that catches model drift over time.
Safety constraints
The system has several layers of protection against false positives:
Confidence threshold. 90% minimum before the deterrent activates.
Protected indices. Kappa's class index is hardcoded as a protected cat. Even if the model returns a score for Kappa, the deterrent controller refuses to fire:
if (result.detectedIndex == KAPPA_INDEX) {
return false; // never, under any circumstances
}
Dry-run mode. During testing, the atomiser is skipped but the LED sequence and video recording run in full. Wolf (the stuffed cat) was extremely patient about this.
Cooldown. After each detection event, the system enters a 30-second window during which further PIR triggers are ignored. Boots does not get to provoke multiple spray cycles by jittering in front of the flap.
The residents
The four cats the system must never squirt:

Chi, Tau, Kappa, and Mu — the blameless.
What happened
Boots got squirted. He looked extremely offended. He came back the following morning, got squirted again, and left. After about a week he stopped trying entirely. My cats remain dry and unbothered. Kappa continues to express his opinions about unrelated matters.
The training corpus, meanwhile, grows every time any cat uses the flap. The model gets smarter. The bowl stays full.
nakomis/bootboots on GitHub
ESP32-S3 firmware, Lambda inference, SageMaker training pipeline, and CDK infrastructure.
Martin Harris builds systems where physical things and cloud infrastructure meet. The cats are not consulted on architectural decisions.
If you find this blog useful, please consider a donation.
