A Raspberry Pi wildlife camera written in Java 26 that watches your yard and tells you who visited.
Edge AI inference, SQLite journaling, and a live HTML-over-the-wire dashboard — all running on a Pi, written entirely in Java 26.
- Watches — USB webcam captures frames on a Virtual Thread
- Detects motion — OpenCV
absdiffbackground subtraction - Classifies — ONNX MobileNet-SSD runs edge inference (90 ms on Pi 4)
- Records — SQLite stores species, confidence, timestamp, image path
- Streams — Server-Sent Events push live HTML cards to every open browser
# 1. Clone and build
just build
# 2. Run (laptop with webcam, or Pi with USB camera)
just run
# 3. Open http://localhost:8081
# Login: wildtype (configurable via wildtype.password)To disable the vision pipeline and use the debug API instead:
just run-debugInject a test sighting:
just debug-sighting| Item | Qty | Est. Cost | Notes |
|---|---|---|---|
| Raspberry Pi 4/5 | 1 | BYOD | 64-bit OS |
| MicroSD + PSU | 1 | BYOD | Already owned |
| USB Webcam (720p) | 1 | $15–25 | Logitech C270 or equivalent |
| 3D-printed tray | 1 | $1 | Single-piece, 30-min print |
| M2.5 screws | 2 | $0 | Pi mounting |
| Total purchased | — | $15–25 | — |
Enclosure: enclosure/tray.scad — print in PLA, 0.3mm layer, 20% infill.
[USB Webcam] → [OpenCV capture loop (Virtual Thread)]
↓
[absdiff motion check]
↓
[MotionEvent] → [ONNX MobileNet-SSD inference]
↓
[DetectionDispatcher (JEP 488 primitive patterns)]
↓
[SQLite INSERT + image save]
↓
[SseEmitter.emit(fragment)] → [Browser EventSource]
↓
[HTML-over-the-wire DOM merge]
Custom gatherers for the detection pipeline:
// Filters consecutive same-label detections within a cooldown window
public static <T> Gatherer<T, ?, T> cooldown(
Function<T, String> keyExtractor, Duration window, double minConfidence) {
return Gatherer.ofSequential(
State::new,
(state, element, downstream) -> {
String key = keyExtractor.apply(element);
return state.allow(key, window) ? downstream.push(element) : true;
});
}// Filters detections below confidence threshold
public static <T> Gatherer<T, ?, T> aboveThreshold(
Function<T, Float> confidenceExtractor, float threshold) {
return Gatherer.ofSequential(
() -> null,
(state, element, downstream) -> {
float confidence = confidenceExtractor.apply(element);
return confidence >= threshold ? downstream.push(element) : true;
});
}Routes COCO class IDs and confidence thresholds without if-else chains:
public String speciesFromCoco(int classId) {
return switch (classId) {
case 1 -> "Person";
case 16 -> "Cat";
case 17 -> "Dog";
case 18 -> "Horse";
case 19 -> "Sheep";
case 20 -> "Cow";
case 21 -> "Elephant";
case 22 -> "Bear";
case 23 -> "Zebra";
case 24 -> "Giraffe";
case int id when id >= 25 && id <= 80 -> "Object";
default -> "Unknown";
};
}
public boolean worthStoring(float confidence) {
return switch ((int) (confidence * 100)) {
case int c when c >= 30 -> true;
default -> false;
};
}No explicit ThreadPoolExecutor anywhere:
# Spring Boot Tomcat serves every request on a virtual thread
server.tomcat.threads.virtual.enabled=true// Vision capture runs on its own virtual thread
@PostConstruct
public void start() {
captureThread = Thread.startVirtualThread(this::runLoop);
}Concurrent workloads — capture, ONNX inference (blocking native call), SQLite write, and multiple open SSE connections — all run on virtual threads.
Context flows from motion detection through inference to the repository layer without parameter drilling or ThreadLocal:
public static final ScopedValue<SightingContext> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, new SightingContext("backyard", Instant.now()))
.run(() -> pipeline.process(frame));
// Inside service layer:
String cam = SightingContextHolder.get().cameraId();| Layer | Tech | Why |
|---|---|---|
| Language | Java 26 | Contest requirement; 4 JEPs showcased |
| Build | Gradle | sourceCompatibility = JavaVersion.VERSION_26 |
| Camera | org.openpnp:opencv |
Pre-built aarch64 native libs |
| Vision AI | ONNX Runtime Java + MobileNet SSD (COCO) | One model, one inference call |
| Database | SQLite + sqlite-jdbc + JdbcTemplate |
Zero-config, file-based, survives reboots |
| Web | Spring Boot 3.5 + Tomcat virtual threads | One-line VT activation |
| Frontend | Thymeleaf + raw SSE | Server pushes HTML fragments; browser merges them |
| Formatting | palantir-java-format | Consistent style across all source |
CREATE TABLE sightings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
detected_at TEXT NOT NULL, -- ISO-8601
species TEXT NOT NULL,
confidence REAL NOT NULL,
image_path TEXT NOT NULL
);
CREATE INDEX idx_sightings_time ON sightings(detected_at DESC);The dashboard uses server-sent HTML fragments instead of JSON APIs:
- Initial page is server-rendered Thymeleaf
EventSourcereceives HTML fragments pushed from the Pi- A 5-line JS snippet merges them into the DOM
No JSON APIs. No React. No polling. Just server-sent HTML fragments via Java Virtual Threads.
# Format all Java
just fmt
# Run tests
just test
# Run with webcam
just run
# Run without vision (debug API only)
just run-debug
# Inject test data
just debug-sightingSee PI_TRANSFER.md for SD card setup, Java 26 install, project transfer, and systemd auto-start.
/— Dashboard with live SSE updates, species badges, and sighting cards/login- Login page/sightings/{id}— Detail page for a single sighting with full-size image
- Code: MIT
- MobileNet-SSD model: Apache 2.0 (ONNX Model Zoo)
- OpenCV: Apache 2.0
- ONNX Runtime: MIT