Spaces:
Running
Running
Deploy verification service: scoring + advisory layer
Browse files- .dockerignore +21 -0
- Dockerfile +55 -0
- README.md +112 -11
- app.py +217 -0
- greenproof_ml/__init__.py +3 -0
- greenproof_ml/advisor.py +386 -0
- greenproof_ml/embed.py +99 -0
- greenproof_ml/pipeline.py +221 -0
- greenproof_ml/plant_reference.npy +3 -0
- greenproof_ml/scoring.py +528 -0
- greenproof_ml/signals.py +309 -0
- greenproof_ml/store.py +278 -0
- requirements.txt +26 -0
.dockerignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep the build context and the image small.
|
| 2 |
+
#
|
| 3 |
+
# Cloud Build uploads the context before building, and Artifact Registry's free
|
| 4 |
+
# tier is 0.5 GB, so anything shipped here is paid for twice: once in upload
|
| 5 |
+
# time on a Ghanaian connection, once in storage.
|
| 6 |
+
|
| 7 |
+
__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
*.pyo
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
|
| 12 |
+
# Tests and the attack harness are developer tools. They are run from a
|
| 13 |
+
# checkout against the live database, never from inside the container, and
|
| 14 |
+
# attack_set.py in particular must not be trivially runnable on the server.
|
| 15 |
+
test_*.py
|
| 16 |
+
attack_set.py
|
| 17 |
+
calibrate.py
|
| 18 |
+
|
| 19 |
+
.env
|
| 20 |
+
.env.*
|
| 21 |
+
*.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# One container, no vendor lock-in. Runs on Cloud Run, Render, a Hugging Face
|
| 2 |
+
# Space, or a laptop. Nothing in here is specific to a host.
|
| 3 |
+
FROM python:3.11-slim
|
| 4 |
+
|
| 5 |
+
# OpenCV needs these even in the headless build.
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
libglib2.0-0 libgl1 \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Run as uid 1000 with a writable $HOME. Hugging Face Spaces requires exactly
|
| 11 |
+
# this, and without a writable HOME huggingface_hub cannot write its model
|
| 12 |
+
# cache and the container dies on first request with a permission error that
|
| 13 |
+
# reads like a network failure.
|
| 14 |
+
RUN useradd -m -u 1000 user
|
| 15 |
+
USER user
|
| 16 |
+
ENV HOME=/home/user \
|
| 17 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 18 |
+
HF_HOME=/home/user/.cache/huggingface \
|
| 19 |
+
PYTHONUNBUFFERED=1
|
| 20 |
+
|
| 21 |
+
WORKDIR $HOME/app
|
| 22 |
+
|
| 23 |
+
COPY --chown=user requirements.txt .
|
| 24 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 25 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 26 |
+
|
| 27 |
+
COPY --chown=user . .
|
| 28 |
+
|
| 29 |
+
# BAKE THE MODEL INTO THE IMAGE. This is the difference between a live demo and
|
| 30 |
+
# ninety seconds of dead air on stage.
|
| 31 |
+
#
|
| 32 |
+
# Cloud Run scales to zero, so the first request after idle starts a cold
|
| 33 |
+
# container. If the weights are not already present, that request also waits on
|
| 34 |
+
# a ~90 MB download from Hugging Face before it can score anything. Downloading
|
| 35 |
+
# at BUILD time moves that cost to a machine nobody is watching, and cold start
|
| 36 |
+
# becomes container boot plus ONNX session init.
|
| 37 |
+
#
|
| 38 |
+
# It also removes a runtime dependency on huggingface.co being reachable and
|
| 39 |
+
# not rate-limiting, which is not a thing to discover during a pitch.
|
| 40 |
+
#
|
| 41 |
+
# Costs ~90 MB of image size. Artifact Registry's free tier is 0.5 GB, so this
|
| 42 |
+
# may tip storage into a few cents a month. Worth it.
|
| 43 |
+
RUN python -c "from greenproof_ml.embed import _model_path; print('baked:', _model_path())"
|
| 44 |
+
|
| 45 |
+
EXPOSE 7860
|
| 46 |
+
|
| 47 |
+
# One worker deliberately: the ONNX session is per-process and ~90 MB of
|
| 48 |
+
# weights, and check-in traffic is a handful of requests per round. Two workers
|
| 49 |
+
# would double memory to serve a queue that is never deep.
|
| 50 |
+
#
|
| 51 |
+
# SHELL FORM, so ${PORT} is expanded at runtime. Every container host injects
|
| 52 |
+
# the port it wants on a different variable-or-default convention, and the
|
| 53 |
+
# exec form ["uvicorn", ...] would pass the literal string "${PORT}" and the
|
| 54 |
+
# container would die on boot with an unreadable error. 7860 is the fallback.
|
| 55 |
+
CMD uvicorn app:app --host 0.0.0.0 --port ${PORT:-7860} --workers 1
|
README.md
CHANGED
|
@@ -1,11 +1,112 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo: gray
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GreenProof Verification
|
| 3 |
+
emoji: 🌳
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# GreenProof verification service
|
| 12 |
+
|
| 13 |
+
Scores a tree check-in and writes back a confidence, a verdict and a per-signal
|
| 14 |
+
breakdown. The only server-side surface in the stack.
|
| 15 |
+
|
| 16 |
+
## Why this exists
|
| 17 |
+
|
| 18 |
+
Everything else in GreenProof is Supabase called directly from the browser. This
|
| 19 |
+
service exists for one reason: **a client-computed verification result is
|
| 20 |
+
forgeable.** The anon key ships inside the PWA and must be assumed public, so
|
| 21 |
+
`confidence`, `verdict` and `signals` are not granted to `authenticated` at the
|
| 22 |
+
column level. This process holds the only key that can write them.
|
| 23 |
+
|
| 24 |
+
`POST /score` takes an id and nothing else. Every input is re-read from the
|
| 25 |
+
database and from storage — a caller can ask for a check-in to be scored, but
|
| 26 |
+
can never influence what the score is.
|
| 27 |
+
|
| 28 |
+
## Endpoints
|
| 29 |
+
|
| 30 |
+
| Method | Path | Purpose |
|
| 31 |
+
|---|---|---|
|
| 32 |
+
| GET | `/health` | liveness, model-loaded, config present |
|
| 33 |
+
| POST | `/score` | score one check-in — `{"checkin_id": "..."}` |
|
| 34 |
+
| POST | `/advise` | species + care advice — **advisory, never a verdict** |
|
| 35 |
+
| POST | `/backfill?limit=50` | score everything still `pending` |
|
| 36 |
+
| GET | `/docs` | interactive OpenAPI |
|
| 37 |
+
|
| 38 |
+
`/advise` is the only endpoint that is not verification. It writes
|
| 39 |
+
`species_guess` and `advice` and cannot touch `confidence`, `verdict` or
|
| 40 |
+
`signals` — enforced by column grants, and by `pipeline.py` never importing the
|
| 41 |
+
advisor. It is also the only endpoint that **spends money** (~3 cents a call),
|
| 42 |
+
which is why it can be locked behind `ADVISE_TOKEN`.
|
| 43 |
+
|
| 44 |
+
## How a check-in is judged
|
| 45 |
+
|
| 46 |
+
**Stage 1 — gates.** Disqualifying on their own, run before any scoring.
|
| 47 |
+
|
| 48 |
+
| Gate | Catches |
|
| 49 |
+
|---|---|
|
| 50 |
+
| duplicate image (pHash) | resubmitted photo, gallery photo, internet photo |
|
| 51 |
+
| GPS radius | right tree, wrong place |
|
| 52 |
+
| travel speed | one account submitting from two impossible places |
|
| 53 |
+
|
| 54 |
+
**Stage 2 — scores.** Continuous 0–1 signals, weighted into a confidence.
|
| 55 |
+
|
| 56 |
+
| Score | Signal |
|
| 57 |
+
|---|---|
|
| 58 |
+
| location | distance from the registered point |
|
| 59 |
+
| liveness | excess-green vegetation fraction |
|
| 60 |
+
| scene match | DINOv2 cosine + ORB/RANSAC inliers vs previous visits |
|
| 61 |
+
| growth | canopy and trunk plausibility, not measurement |
|
| 62 |
+
|
| 63 |
+
Five of the six fraud types in our attack set are caught in stage 1 by ordinary
|
| 64 |
+
deterministic code. That is the honest reason the system works, and it is why
|
| 65 |
+
overall accuracy is much better than the 54% rank-1 of image matching alone.
|
| 66 |
+
|
| 67 |
+
## Honest limits
|
| 68 |
+
|
| 69 |
+
- Image matching is **corroboration, not identity**. Measured on our own 15
|
| 70 |
+
plants: rank-1 54% against 5% chance, AUC 0.77 — real signal, nowhere near
|
| 71 |
+
enough to authorise a payout. It is **bimodal**: near-perfect on distinctive
|
| 72 |
+
trees, near-zero inside a dense same-species stand.
|
| 73 |
+
- Wide-shot matching works partly off the **background**, not the tree.
|
| 74 |
+
- Young bark is smooth; BarkNet's ~94% figures are on **mature** bark.
|
| 75 |
+
- Thresholds and weights in `scoring.py` are **provisional**. They are replaced
|
| 76 |
+
by weights fitted under leave-one-tree-out cross-validation once real rounds
|
| 77 |
+
exist. Nothing is tuned on the data used to report performance.
|
| 78 |
+
- A missing signal lowers confidence and routes to a human. It never counts as
|
| 79 |
+
zero, and it never fails closed on a genuine visit. Designed to degrade.
|
| 80 |
+
|
| 81 |
+
## Configuration
|
| 82 |
+
|
| 83 |
+
Space → Settings → Variables and secrets:
|
| 84 |
+
|
| 85 |
+
| Name | Kind | Value |
|
| 86 |
+
|---|---|---|
|
| 87 |
+
| `SUPABASE_URL` | variable | your project URL |
|
| 88 |
+
| `SUPABASE_SERVICE_KEY` | **secret** | the `service_role` key |
|
| 89 |
+
| `ANTHROPIC_API_KEY` | **secret** | for `/advise` only — omit and advice is simply disabled |
|
| 90 |
+
| `ADVISE_TOKEN` | **secret** | any random string; required in `X-Advise-Token` on `/advise` |
|
| 91 |
+
| `ALLOWED_ORIGINS` | variable | your Vercel URL |
|
| 92 |
+
|
| 93 |
+
**The service key must never appear in the frontend.** It can write any verdict
|
| 94 |
+
for any tree, and it is the value the whole security model rests on.
|
| 95 |
+
|
| 96 |
+
**`ANTHROPIC_API_KEY` is billable.** A leak is someone else's spending. Omitting
|
| 97 |
+
it is a supported state, not a failure: `/score` is unaffected and `/advise`
|
| 98 |
+
returns `{"written": false}` — the advisory layer fails soft by design so it can
|
| 99 |
+
never take verification down with it.
|
| 100 |
+
|
| 101 |
+
**`ADVISE_TOKEN` matters on a public Space.** Without it, anyone who finds this
|
| 102 |
+
URL and a valid check-in id can spend your Anthropic credit three cents at a
|
| 103 |
+
time. `/score` needs no such lock: it costs only our own CPU.
|
| 104 |
+
|
| 105 |
+
## Local run
|
| 106 |
+
|
| 107 |
+
```
|
| 108 |
+
cd ml
|
| 109 |
+
pip install -r requirements.txt
|
| 110 |
+
export SUPABASE_URL=... SUPABASE_SERVICE_KEY=...
|
| 111 |
+
uvicorn app:app --reload --port 7860
|
| 112 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GreenProof verification service.
|
| 2 |
+
|
| 3 |
+
The ONE new surface in the stack. Everything else is Supabase called directly
|
| 4 |
+
from the browser; this exists because a client-computed verification result is
|
| 5 |
+
forgeable, so scoring has to happen somewhere the user cannot reach.
|
| 6 |
+
|
| 7 |
+
Runs on Hugging Face Spaces (Docker SDK). Host-agnostic: the same container
|
| 8 |
+
runs on a laptop, Cloud Run, or anywhere else that can run Docker.
|
| 9 |
+
|
| 10 |
+
GET / service metadata
|
| 11 |
+
GET /health liveness + whether the model is loaded
|
| 12 |
+
POST /score score one check-in by id
|
| 13 |
+
POST /advise species + care advice for one check-in (advisory only)
|
| 14 |
+
POST /backfill score every pending check-in (used after T0)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
import os
|
| 21 |
+
import secrets
|
| 22 |
+
import threading
|
| 23 |
+
|
| 24 |
+
from fastapi import FastAPI, Header, HTTPException
|
| 25 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 26 |
+
from pydantic import BaseModel, Field
|
| 27 |
+
|
| 28 |
+
from greenproof_ml import embed as embed_mod
|
| 29 |
+
from greenproof_ml import store
|
| 30 |
+
from greenproof_ml.pipeline import score_checkin
|
| 31 |
+
from greenproof_ml.scoring import MODEL_NAME, MODEL_VERSION
|
| 32 |
+
|
| 33 |
+
logging.basicConfig(
|
| 34 |
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
|
| 35 |
+
)
|
| 36 |
+
log = logging.getLogger("greenproof")
|
| 37 |
+
|
| 38 |
+
app = FastAPI(title="GreenProof verification service", version=MODEL_VERSION)
|
| 39 |
+
|
| 40 |
+
# The PWA is served from Vercel, so this is a genuine cross-origin call.
|
| 41 |
+
# Set ALLOWED_ORIGINS to the Vercel URL in the Space's variables; the default
|
| 42 |
+
# is permissive so the pilot is never blocked by a CORS typo at 6am, and the
|
| 43 |
+
# endpoints carry no secrets a caller could extract — the service key stays
|
| 44 |
+
# server-side and every write is derived from stored photos, not from the
|
| 45 |
+
# request body.
|
| 46 |
+
origins = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
|
| 47 |
+
app.add_middleware(
|
| 48 |
+
CORSMiddleware,
|
| 49 |
+
allow_origins=[o.strip() for o in origins if o.strip()],
|
| 50 |
+
allow_methods=["GET", "POST"],
|
| 51 |
+
allow_headers=["*"],
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
_model_ready = threading.Event()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.on_event("startup")
|
| 58 |
+
def _warm() -> None:
|
| 59 |
+
"""Load the model in the background.
|
| 60 |
+
|
| 61 |
+
Spaces health-check the container early. Blocking startup on a ~90 MB model
|
| 62 |
+
download makes the Space look dead and get restarted, which restarts the
|
| 63 |
+
download — a loop that has eaten whole afternoons.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
def run() -> None:
|
| 67 |
+
try:
|
| 68 |
+
embed_mod.warm()
|
| 69 |
+
_model_ready.set()
|
| 70 |
+
log.info("model ready: %s %s", MODEL_NAME, MODEL_VERSION)
|
| 71 |
+
except Exception:
|
| 72 |
+
log.exception("model failed to load")
|
| 73 |
+
|
| 74 |
+
threading.Thread(target=run, daemon=True).start()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class ScoreRequest(BaseModel):
|
| 78 |
+
checkin_id: str = Field(..., description="checkins.id to score")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ScoreResponse(BaseModel):
|
| 82 |
+
checkin_id: str
|
| 83 |
+
confidence: int
|
| 84 |
+
verdict: str
|
| 85 |
+
signals: dict
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@app.get("/")
|
| 89 |
+
def root() -> dict:
|
| 90 |
+
return {
|
| 91 |
+
"service": "greenproof-verification",
|
| 92 |
+
"model": MODEL_NAME,
|
| 93 |
+
"version": MODEL_VERSION,
|
| 94 |
+
"docs": "/docs",
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.get("/health")
|
| 99 |
+
def health() -> dict:
|
| 100 |
+
return {
|
| 101 |
+
"ok": True,
|
| 102 |
+
"model_ready": _model_ready.is_set(),
|
| 103 |
+
"supabase_configured": bool(
|
| 104 |
+
os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_SERVICE_KEY")
|
| 105 |
+
),
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@app.post("/score", response_model=ScoreResponse)
|
| 110 |
+
def score(req: ScoreRequest) -> ScoreResponse:
|
| 111 |
+
"""Score one check-in.
|
| 112 |
+
|
| 113 |
+
Takes only an id. The request body cannot influence the outcome — every
|
| 114 |
+
input is re-read from the database and from storage. A caller can ask for a
|
| 115 |
+
check-in to be scored; it can never say what the score should be.
|
| 116 |
+
"""
|
| 117 |
+
if not _model_ready.is_set():
|
| 118 |
+
raise HTTPException(503, "Model still loading, retry shortly")
|
| 119 |
+
try:
|
| 120 |
+
result = score_checkin(req.checkin_id)
|
| 121 |
+
except LookupError as e:
|
| 122 |
+
raise HTTPException(404, str(e)) from e
|
| 123 |
+
except Exception as e: # noqa: BLE001
|
| 124 |
+
log.exception("scoring failed for %s", req.checkin_id)
|
| 125 |
+
raise HTTPException(500, f"Scoring failed: {e}") from e
|
| 126 |
+
|
| 127 |
+
return ScoreResponse(
|
| 128 |
+
checkin_id=req.checkin_id,
|
| 129 |
+
confidence=result.confidence,
|
| 130 |
+
verdict=result.verdict,
|
| 131 |
+
signals=result.signals,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# Optional shared secret for /advise. Unset means the endpoint is open.
|
| 136 |
+
#
|
| 137 |
+
# WHY THIS EXISTS, AND WHY ONLY ON THIS ENDPOINT.
|
| 138 |
+
#
|
| 139 |
+
# /score is safe to leave open: it takes an id, re-reads every input from the
|
| 140 |
+
# database, and costs us nothing but a few seconds of our own CPU. /advise is
|
| 141 |
+
# different in one specific way - IT SPENDS MONEY. Each call is about 3 cents of
|
| 142 |
+
# Anthropic usage.
|
| 143 |
+
#
|
| 144 |
+
# On a Cloudflare quick tunnel that barely mattered: the hostname rotated and the
|
| 145 |
+
# service was up for minutes at a time. A permanent public Space URL is a
|
| 146 |
+
# different proposition, and an endpoint that bills the operator per request is
|
| 147 |
+
# worth a lock even when the realistic risk is low.
|
| 148 |
+
#
|
| 149 |
+
# Unset by default so local runs and `uvicorn app:app` need no configuration.
|
| 150 |
+
# Set it in the Space's secrets and in .env, and tools/advise_pilot.py sends it.
|
| 151 |
+
ADVISE_TOKEN = os.environ.get("ADVISE_TOKEN", "").strip()
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@app.post("/advise")
|
| 155 |
+
def advise(req: ScoreRequest, x_advise_token: str = Header(default="")) -> dict:
|
| 156 |
+
"""Species identification and care advice for one check-in.
|
| 157 |
+
|
| 158 |
+
SEPARATE FROM /score ON PURPOSE, and the separation is the design.
|
| 159 |
+
|
| 160 |
+
Scoring is fast, local, offline-capable and replayable - it re-runs over the
|
| 161 |
+
whole pilot dataset whenever a threshold moves, and it must never acquire a
|
| 162 |
+
dependency on an external API that can be slow, rate-limited or down. This
|
| 163 |
+
endpoint is none of those things: it makes a paid network call to a model
|
| 164 |
+
whose error rate we have not measured.
|
| 165 |
+
|
| 166 |
+
So they share a service and nothing else. This writes only `species_guess`
|
| 167 |
+
and `advice`; it cannot move a confidence or a verdict, and a failure here
|
| 168 |
+
leaves the check-in exactly as scoring left it.
|
| 169 |
+
|
| 170 |
+
Note it does NOT require the DINOv2 model to be loaded - the two paths have
|
| 171 |
+
no components in common, so a cold model should not block advice.
|
| 172 |
+
"""
|
| 173 |
+
# compare_digest, not ==, so a wrong token cannot be recovered by timing.
|
| 174 |
+
if ADVISE_TOKEN and not secrets.compare_digest(x_advise_token, ADVISE_TOKEN):
|
| 175 |
+
raise HTTPException(401, "Missing or invalid X-Advise-Token")
|
| 176 |
+
|
| 177 |
+
from greenproof_ml import advisor
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
result = advisor.advise_checkin(req.checkin_id)
|
| 181 |
+
except LookupError as e:
|
| 182 |
+
raise HTTPException(404, str(e)) from e
|
| 183 |
+
except Exception as e: # noqa: BLE001
|
| 184 |
+
log.exception("advice failed for %s", req.checkin_id)
|
| 185 |
+
raise HTTPException(500, f"Advice failed: {e}") from e
|
| 186 |
+
|
| 187 |
+
if result is None:
|
| 188 |
+
# Not an error. Either the tree shows no decline and the policy skipped
|
| 189 |
+
# it, or the model declined to answer. Both leave the row untouched.
|
| 190 |
+
return {"checkin_id": req.checkin_id, "written": False}
|
| 191 |
+
|
| 192 |
+
return {"checkin_id": req.checkin_id, "written": True, **result}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@app.post("/backfill")
|
| 196 |
+
def backfill(limit: int = 50) -> dict:
|
| 197 |
+
"""Score everything still pending.
|
| 198 |
+
|
| 199 |
+
This is what makes T0 safe to run before the service exists: registration
|
| 200 |
+
only captures and uploads, and the photos sit as `pending` until this is
|
| 201 |
+
called. Nothing about the pilot depends on the ML service being live on the
|
| 202 |
+
day.
|
| 203 |
+
"""
|
| 204 |
+
if not _model_ready.is_set():
|
| 205 |
+
raise HTTPException(503, "Model still loading, retry shortly")
|
| 206 |
+
|
| 207 |
+
rows = store.list_pending(limit)
|
| 208 |
+
done, failed = [], []
|
| 209 |
+
for row in rows:
|
| 210 |
+
try:
|
| 211 |
+
result = score_checkin(row["id"])
|
| 212 |
+
done.append({"id": row["id"], "verdict": result.verdict, "confidence": result.confidence})
|
| 213 |
+
except Exception as e: # noqa: BLE001
|
| 214 |
+
log.exception("backfill failed for %s", row["id"])
|
| 215 |
+
failed.append({"id": row["id"], "error": str(e)})
|
| 216 |
+
|
| 217 |
+
return {"scored": len(done), "failed": len(failed), "results": done, "errors": failed}
|
greenproof_ml/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GreenProof verification signals, scoring and persistence."""
|
| 2 |
+
|
| 3 |
+
__version__ = "2026.08.1"
|
greenproof_ml/advisor.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Species identification and care advice for the planter.
|
| 2 |
+
|
| 3 |
+
THE ONE THING TO UNDERSTAND ABOUT THIS FILE
|
| 4 |
+
|
| 5 |
+
Nothing here is a verification signal, and nothing here may become one.
|
| 6 |
+
|
| 7 |
+
The verification engine answers "is this the same tree, alive, in the right
|
| 8 |
+
place, not a duplicate" - and it answers it with detectors whose error rate we
|
| 9 |
+
have measured and published. This file answers a different question that nobody
|
| 10 |
+
has been answering at all: "what is wrong with this tree and what should the
|
| 11 |
+
planter do about it?"
|
| 12 |
+
|
| 13 |
+
Those two questions deserve different standards of evidence, so they are kept
|
| 14 |
+
apart at every level: a separate module THE SCORING PATH NEVER IMPORTS, a
|
| 15 |
+
separate endpoint, separate database columns, and a separate place in the UI
|
| 16 |
+
that names the model and says the word "advice".
|
| 17 |
+
|
| 18 |
+
The import direction is the load-bearing part. `pipeline.py` does not know this
|
| 19 |
+
file exists, so there is no code path by which a slow, failed or hallucinated
|
| 20 |
+
advisory call can affect a verdict. Check that property still holds before
|
| 21 |
+
adding any import to this module's callers:
|
| 22 |
+
|
| 23 |
+
grep -rn "advisor" ml/greenproof_ml/pipeline.py ml/greenproof_ml/scoring.py
|
| 24 |
+
|
| 25 |
+
should print nothing, permanently.
|
| 26 |
+
|
| 27 |
+
WHY AN LLM HERE, WHEN WE REFUSED ONE EVERYWHERE ELSE
|
| 28 |
+
|
| 29 |
+
The obvious alternative is a leaf-disease classifier trained on PlantVillage.
|
| 30 |
+
We are not doing that, for three reasons and the first is disqualifying:
|
| 31 |
+
|
| 32 |
+
1. PlantVillage is single leaves on uniform lab backgrounds. Published
|
| 33 |
+
cross-domain evaluations collapse from ~99% to roughly 30-50% on real
|
| 34 |
+
field photographs. We have already made exactly this mistake once and
|
| 35 |
+
turned it into our best slide: the plant check scored a perfect AUC of
|
| 36 |
+
1.000 and then flagged five of nine real check-ins as "not a plant",
|
| 37 |
+
because it had learned photo STYLE, not subject. Shipping a PlantVillage
|
| 38 |
+
classifier would be repeating a mistake we have documented.
|
| 39 |
+
|
| 40 |
+
2. It covers fourteen crops - tomato, potato, corn, grape. None of them are
|
| 41 |
+
Odum, Wawa, Ofram, Ceiba, or anything else in a Ghanaian planting scheme.
|
| 42 |
+
|
| 43 |
+
3. We have no ground truth. No agronomist has labelled our trees. This
|
| 44 |
+
project's entire claim is that we publish our own measured error rate, and
|
| 45 |
+
a classifier whose error rate we cannot measure would be the one component
|
| 46 |
+
contradicting that claim.
|
| 47 |
+
|
| 48 |
+
An LLM's output is hedged natural language that a person reads and judges, not
|
| 49 |
+
a number that authorises a payment. The bar it has to clear is therefore much
|
| 50 |
+
lower, and it is honest about clearing a lower bar - which is why `limitations`
|
| 51 |
+
below is a required field rather than an optional one.
|
| 52 |
+
|
| 53 |
+
FAILS SOFT, ALWAYS. No key, no network, a rate limit, a refusal, a malformed
|
| 54 |
+
response: every one of them logs and returns None. The caller writes nothing and
|
| 55 |
+
the check-in is untouched. This service being unreachable already costs us
|
| 56 |
+
nothing (scoring is asynchronous and replayable); the advisory layer inherits
|
| 57 |
+
that property rather than weakening it.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
from __future__ import annotations
|
| 61 |
+
|
| 62 |
+
import base64
|
| 63 |
+
import io
|
| 64 |
+
import logging
|
| 65 |
+
import os
|
| 66 |
+
from typing import Literal
|
| 67 |
+
|
| 68 |
+
from PIL import Image
|
| 69 |
+
from pydantic import BaseModel, Field
|
| 70 |
+
|
| 71 |
+
log = logging.getLogger(__name__)
|
| 72 |
+
|
| 73 |
+
# Opus 5. The images are small and the call is off the critical path, so there
|
| 74 |
+
# is no reason to trade quality away here - this is the output a planter reads
|
| 75 |
+
# and acts on, and bad advice about a real tree is worse than no advice.
|
| 76 |
+
MODEL = "claude-opus-5"
|
| 77 |
+
|
| 78 |
+
# Generous. The call is fire-and-forget from the caller's perspective and a slow
|
| 79 |
+
# response costs nothing, whereas a truncated one wastes the whole request.
|
| 80 |
+
MAX_TOKENS = 2000
|
| 81 |
+
|
| 82 |
+
# Images are re-encoded to this before sending. The photos in storage are
|
| 83 |
+
# already 800px/q75 (the client downscales before upload), so this is a ceiling
|
| 84 |
+
# rather than a resize in the normal case, and it bounds the token cost of a
|
| 85 |
+
# photo that arrived by some other route.
|
| 86 |
+
MAX_EDGE_PX = 800
|
| 87 |
+
JPEG_QUALITY = 75
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class PlantAssessment(BaseModel):
|
| 91 |
+
"""What we ask Claude to return, and what we store.
|
| 92 |
+
|
| 93 |
+
Every field is chosen so that a reader can tell how much to trust it.
|
| 94 |
+
`species_confidence` is SELF-REPORTED and labelled as such in the UI - it is
|
| 95 |
+
not a measured accuracy and must never be presented as one.
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
species_common: str | None = Field(
|
| 99 |
+
description="Common name of the species, or null if not identifiable."
|
| 100 |
+
)
|
| 101 |
+
species_scientific: str | None = Field(
|
| 102 |
+
description="Binomial scientific name, or null if not identifiable."
|
| 103 |
+
)
|
| 104 |
+
species_confidence: float = Field(
|
| 105 |
+
ge=0.0,
|
| 106 |
+
le=1.0,
|
| 107 |
+
description="Your own confidence in the species identification, 0 to 1.",
|
| 108 |
+
)
|
| 109 |
+
health: Literal["healthy", "stressed", "declining", "cannot_tell"] = Field(
|
| 110 |
+
description="Overall condition of the plant as far as the photos show it."
|
| 111 |
+
)
|
| 112 |
+
observations: list[str] = Field(
|
| 113 |
+
description="What is actually visible in the photographs. Concrete and "
|
| 114 |
+
"specific: leaf colour, leaf loss, wilting, damage, the state of the "
|
| 115 |
+
"soil. Do not speculate beyond what the image shows."
|
| 116 |
+
)
|
| 117 |
+
actions: list[str] = Field(
|
| 118 |
+
description="What the planter should do, in plain language, achievable "
|
| 119 |
+
"by one person with no equipment and no money."
|
| 120 |
+
)
|
| 121 |
+
limitations: str = Field(
|
| 122 |
+
description="What these photographs could NOT tell you, and what would "
|
| 123 |
+
"need to be checked in person."
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# `limitations` being required is the design, not a formality. An assessment
|
| 128 |
+
# that cannot say what it failed to see is indistinguishable from one that saw
|
| 129 |
+
# everything, and a planter cannot calibrate how much to trust it.
|
| 130 |
+
SYSTEM = """You are advising a smallholder tree planter in Ghana who has \
|
| 131 |
+
photographed a young tree (roughly 1-3 years old) they are responsible for \
|
| 132 |
+
keeping alive. They are paid when the tree survives, so your advice has real \
|
| 133 |
+
consequences for them.
|
| 134 |
+
|
| 135 |
+
You will receive one to three photographs of the same tree from the same visit: \
|
| 136 |
+
a wide shot of the whole tree and its surroundings, a close-up of the trunk, \
|
| 137 |
+
and sometimes a close-up of a leaf.
|
| 138 |
+
|
| 139 |
+
Your job is to identify the species if you can, describe the tree's condition, \
|
| 140 |
+
and tell the planter what to do about it.
|
| 141 |
+
|
| 142 |
+
Rules:
|
| 143 |
+
|
| 144 |
+
- Describe only what is visible. If the photographs do not show something, say \
|
| 145 |
+
so in `limitations` rather than guessing at it.
|
| 146 |
+
- Prefer species common in Ghanaian planting schemes where the image supports \
|
| 147 |
+
it (Odum/Milicia, Wawa/Triplochiton, Ofram/Terminalia, Mahogany/Khaya, \
|
| 148 |
+
Neem/Azadirachta, Ceiba, Mango/Mangifera, Cassia, Acacia, Teak/Tectona), but do \
|
| 149 |
+
not force a match. Return null for species rather than a bad guess, and let \
|
| 150 |
+
`species_confidence` reflect genuine uncertainty.
|
| 151 |
+
- Recommend only actions a person can take with their hands, water, mulch and \
|
| 152 |
+
local materials. No paid inputs, no laboratory tests, no equipment. Watering, \
|
| 153 |
+
mulching, weeding around the base, removing competing growth, staking, \
|
| 154 |
+
protecting from livestock, and clearing termite damage are the realistic \
|
| 155 |
+
interventions.
|
| 156 |
+
- If the tree looks healthy, say so plainly and give one or two things worth \
|
| 157 |
+
keeping up. Do not invent problems.
|
| 158 |
+
- Be brief. Two to four observations, two to four actions. This is read on a \
|
| 159 |
+
phone, outdoors, by someone standing in front of the tree.
|
| 160 |
+
- Never mention verification, scoring, confidence scores or payment. That is a \
|
| 161 |
+
different part of this system and not your concern."""
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _client():
|
| 165 |
+
"""Constructed per call, deliberately.
|
| 166 |
+
|
| 167 |
+
The API key is read from the environment at call time rather than import
|
| 168 |
+
time, so the module imports cleanly on a machine that has no key - which is
|
| 169 |
+
every machine running the scoring path, since scoring must never acquire a
|
| 170 |
+
dependency on this file.
|
| 171 |
+
"""
|
| 172 |
+
import anthropic # imported here so a missing SDK cannot break scoring
|
| 173 |
+
|
| 174 |
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
| 175 |
+
raise RuntimeError(
|
| 176 |
+
"ANTHROPIC_API_KEY is not set. Note that a Claude Pro subscription "
|
| 177 |
+
"is NOT API access - the API bills separately at console.anthropic.com."
|
| 178 |
+
)
|
| 179 |
+
return anthropic.Anthropic()
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _image_block(img: Image.Image, label: str) -> list[dict]:
|
| 183 |
+
"""One photo as a labelled pair of content blocks.
|
| 184 |
+
|
| 185 |
+
The text label matters: without it the model cannot tell a trunk close-up
|
| 186 |
+
from a leaf close-up, and will describe bark as foliage.
|
| 187 |
+
"""
|
| 188 |
+
im = img.convert("RGB")
|
| 189 |
+
im.thumbnail((MAX_EDGE_PX, MAX_EDGE_PX))
|
| 190 |
+
|
| 191 |
+
buf = io.BytesIO()
|
| 192 |
+
im.save(buf, format="JPEG", quality=JPEG_QUALITY)
|
| 193 |
+
data = base64.standard_b64encode(buf.getvalue()).decode("utf-8")
|
| 194 |
+
|
| 195 |
+
return [
|
| 196 |
+
{"type": "text", "text": label},
|
| 197 |
+
{
|
| 198 |
+
"type": "image",
|
| 199 |
+
"source": {"type": "base64", "media_type": "image/jpeg", "data": data},
|
| 200 |
+
},
|
| 201 |
+
]
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def assess(
|
| 205 |
+
*,
|
| 206 |
+
wide: Image.Image | None = None,
|
| 207 |
+
close: Image.Image | None = None,
|
| 208 |
+
leaf: Image.Image | None = None,
|
| 209 |
+
recorded_species: str | None = None,
|
| 210 |
+
) -> PlantAssessment | None:
|
| 211 |
+
"""Identify the species and assess the tree's health. None on any failure.
|
| 212 |
+
|
| 213 |
+
EVERY PHOTO THAT EXISTS IS SENT, and none is required. That is what lets
|
| 214 |
+
this run against the pilot check-ins captured before `leaf_photo` existed,
|
| 215 |
+
and what lets it improve on its own as leaf photos start arriving - the same
|
| 216 |
+
property the plant reference set has, where every verified check-in makes
|
| 217 |
+
the next assessment slightly better.
|
| 218 |
+
|
| 219 |
+
`recorded_species` is what the planter typed at registration. It is passed
|
| 220 |
+
as CONTEXT TO DISAGREE WITH, never as an answer to confirm: the whole value
|
| 221 |
+
of an independent identification is lost if we tell the model what to say.
|
| 222 |
+
"""
|
| 223 |
+
blocks: list[dict] = []
|
| 224 |
+
if wide is not None:
|
| 225 |
+
blocks += _image_block(wide, "Wide shot: the whole tree and its surroundings.")
|
| 226 |
+
if close is not None:
|
| 227 |
+
blocks += _image_block(close, "Close-up of the trunk.")
|
| 228 |
+
if leaf is not None:
|
| 229 |
+
blocks += _image_block(leaf, "Close-up of a leaf.")
|
| 230 |
+
|
| 231 |
+
if not blocks:
|
| 232 |
+
log.warning("advisor.assess called with no photographs")
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
prompt = "Identify this tree and assess its condition."
|
| 236 |
+
if recorded_species:
|
| 237 |
+
# Framed to invite contradiction. "The planter recorded X" is a claim to
|
| 238 |
+
# test; "this is an X" would be an instruction to agree.
|
| 239 |
+
prompt += (
|
| 240 |
+
f"\n\nThe planter recorded this tree's species as '{recorded_species}' "
|
| 241 |
+
"when they registered it. Treat that as an unverified claim, not as "
|
| 242 |
+
"the answer. If the photographs show something else, say so."
|
| 243 |
+
)
|
| 244 |
+
blocks.append({"type": "text", "text": prompt})
|
| 245 |
+
|
| 246 |
+
try:
|
| 247 |
+
response = _client().messages.parse(
|
| 248 |
+
model=MODEL,
|
| 249 |
+
max_tokens=MAX_TOKENS,
|
| 250 |
+
system=SYSTEM,
|
| 251 |
+
messages=[{"role": "user", "content": blocks}],
|
| 252 |
+
output_format=PlantAssessment,
|
| 253 |
+
)
|
| 254 |
+
except Exception: # noqa: BLE001 - advisory only, must never break a caller
|
| 255 |
+
log.exception("advisory assessment failed")
|
| 256 |
+
return None
|
| 257 |
+
|
| 258 |
+
# A refusal is a successful HTTP call with no parsed output. Guard before
|
| 259 |
+
# reading rather than raising out of a function documented never to raise.
|
| 260 |
+
parsed = getattr(response, "parsed_output", None)
|
| 261 |
+
if parsed is None:
|
| 262 |
+
log.warning(
|
| 263 |
+
"advisory assessment returned no parsed output (stop_reason=%s)",
|
| 264 |
+
getattr(response, "stop_reason", None),
|
| 265 |
+
)
|
| 266 |
+
return None
|
| 267 |
+
|
| 268 |
+
return parsed
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def split(assessment: PlantAssessment) -> tuple[dict, dict]:
|
| 272 |
+
"""One assessment -> the two columns it is stored in.
|
| 273 |
+
|
| 274 |
+
Species and advice are separated in the database because they have different
|
| 275 |
+
futures: species is a candidate verification signal (a species that changes
|
| 276 |
+
between visits is evidence of a swapped tree), and advice never will be.
|
| 277 |
+
Storing them together would make that separation a refactor later.
|
| 278 |
+
"""
|
| 279 |
+
species = {
|
| 280 |
+
"common": assessment.species_common,
|
| 281 |
+
"scientific": assessment.species_scientific,
|
| 282 |
+
# Named to make its nature unmissable at every layer. This is the
|
| 283 |
+
# model's opinion of itself, not a measured accuracy, and calling the
|
| 284 |
+
# field `confidence` next to a column that genuinely IS a measured
|
| 285 |
+
# confidence would be actively misleading.
|
| 286 |
+
"self_reported_confidence": assessment.species_confidence,
|
| 287 |
+
"model": MODEL,
|
| 288 |
+
}
|
| 289 |
+
advice = {
|
| 290 |
+
"health": assessment.health,
|
| 291 |
+
"observations": assessment.observations,
|
| 292 |
+
"actions": assessment.actions,
|
| 293 |
+
"limitations": assessment.limitations,
|
| 294 |
+
"model": MODEL,
|
| 295 |
+
"advisory_only": True,
|
| 296 |
+
}
|
| 297 |
+
return species, advice
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ---------------------------------------------------------------------------
|
| 301 |
+
# When to spend a call
|
| 302 |
+
# ---------------------------------------------------------------------------
|
| 303 |
+
#
|
| 304 |
+
# THE DESIGNED POLICY IS DECLINE-ONLY. The deterministic canopy signal in
|
| 305 |
+
# scoring.score_growth already flags a tree that has lost more than half its
|
| 306 |
+
# canopy since the last visit - that is the dying-tree detector, it costs
|
| 307 |
+
# nothing, and it is measured. Calling an LLM only when that fires is what keeps
|
| 308 |
+
# the per-tree cost defensible at scale:
|
| 309 |
+
#
|
| 310 |
+
# ~3 cents a call. One million trees checked four times a year is
|
| 311 |
+
# ~$120k on every visit, ~$6-12k on decline only.
|
| 312 |
+
#
|
| 313 |
+
# WHY IT IS CURRENTLY TRUE FOR EVERY CHECK-IN ANYWAY. The pilot is 6 trees and
|
| 314 |
+
# 9 check-ins, and most of those are first visits - where score_growth returns a
|
| 315 |
+
# neutral 0.5 because there is no previous canopy to compare against. A
|
| 316 |
+
# decline-only trigger would fire zero times on the data we actually have, and
|
| 317 |
+
# an advisory layer with nothing to advise on is not a feature.
|
| 318 |
+
#
|
| 319 |
+
# So this is a policy constant with both branches live, rather than a hardcoded
|
| 320 |
+
# call site. Both statements are true and both are defensible: we assess every
|
| 321 |
+
# visit at pilot scale, and the design for scale is decline-only.
|
| 322 |
+
ADVISE_ON_EVERY_CHECKIN = True
|
| 323 |
+
|
| 324 |
+
# Below this growth score, the tree is declining enough to be worth advice.
|
| 325 |
+
# Matches the 0.15 that score_growth assigns to >50% canopy loss.
|
| 326 |
+
DECLINE_GROWTH_SCORE = 0.2
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def should_advise(checkin: dict) -> bool:
|
| 330 |
+
"""Is this check-in worth spending an API call on?"""
|
| 331 |
+
if ADVISE_ON_EVERY_CHECKIN:
|
| 332 |
+
return True
|
| 333 |
+
|
| 334 |
+
growth = ((checkin.get("signals") or {}).get("scores") or {}).get("growth") or {}
|
| 335 |
+
score = growth.get("score")
|
| 336 |
+
# An unscored check-in has no decline evidence either way. Advising on it
|
| 337 |
+
# would quietly restore "every check-in" through the back door.
|
| 338 |
+
return score is not None and score <= DECLINE_GROWTH_SCORE
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def advise_checkin(checkin_id: str) -> dict | None:
|
| 342 |
+
"""Assess one check-in and store the result. None if nothing was written.
|
| 343 |
+
|
| 344 |
+
Fetches its own photos from storage, exactly as scoring does, so the request
|
| 345 |
+
body cannot influence what gets assessed.
|
| 346 |
+
"""
|
| 347 |
+
from . import store # local: keeps the module importable without credentials
|
| 348 |
+
|
| 349 |
+
row = store.get_checkin(checkin_id)
|
| 350 |
+
if row is None:
|
| 351 |
+
raise LookupError(f"No check-in {checkin_id}")
|
| 352 |
+
|
| 353 |
+
if not should_advise(row):
|
| 354 |
+
log.info("skipping advice for %s: no decline detected", checkin_id)
|
| 355 |
+
return None
|
| 356 |
+
|
| 357 |
+
tree = store.get_tree(row["tree_id"])
|
| 358 |
+
|
| 359 |
+
def _photo(path: str | None) -> Image.Image | None:
|
| 360 |
+
"""A missing or unreadable photo costs us that photo, never the call."""
|
| 361 |
+
if not path:
|
| 362 |
+
return None
|
| 363 |
+
try:
|
| 364 |
+
return store.download_image(path)
|
| 365 |
+
except Exception: # noqa: BLE001
|
| 366 |
+
log.warning("could not download %s", path, exc_info=True)
|
| 367 |
+
return None
|
| 368 |
+
|
| 369 |
+
assessment = assess(
|
| 370 |
+
wide=_photo(row.get("wide_photo")),
|
| 371 |
+
close=_photo(row.get("close_photo")),
|
| 372 |
+
leaf=_photo(row.get("leaf_photo")),
|
| 373 |
+
recorded_species=(tree or {}).get("species"),
|
| 374 |
+
)
|
| 375 |
+
if assessment is None:
|
| 376 |
+
return None
|
| 377 |
+
|
| 378 |
+
species, advice = split(assessment)
|
| 379 |
+
store.write_advice(checkin_id, species, advice)
|
| 380 |
+
log.info(
|
| 381 |
+
"advised %s -> %s (species: %s)",
|
| 382 |
+
checkin_id,
|
| 383 |
+
assessment.health,
|
| 384 |
+
assessment.species_common,
|
| 385 |
+
)
|
| 386 |
+
return {"species_guess": species, "advice": advice}
|
greenproof_ml/embed.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DINOv2 embeddings via ONNX Runtime.
|
| 2 |
+
|
| 3 |
+
Preprocessing is deliberately IDENTICAL to spike/measure.py. That file produced
|
| 4 |
+
the numbers we publish — rank-1 54.2%, AUC 0.77 — and if production resized or
|
| 5 |
+
normalised differently, those numbers would no longer describe this system. The
|
| 6 |
+
published figure has to be a measurement of the thing we shipped.
|
| 7 |
+
|
| 8 |
+
The one intentional difference: the spike cropped a camera-app timestamp
|
| 9 |
+
watermark off the bottom of every frame. App photos have no watermark, so the
|
| 10 |
+
crop is zero here and configurable rather than deleted, so the spike can be
|
| 11 |
+
re-run against production-shaped inputs.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import functools
|
| 17 |
+
import threading
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
from PIL import Image
|
| 21 |
+
|
| 22 |
+
MODEL_REPO = "onnx-community/dinov2-small"
|
| 23 |
+
|
| 24 |
+
# DINOv2 uses 14px patches; 224 = 16 x 14.
|
| 25 |
+
IMG_SIZE = 224
|
| 26 |
+
EMBED_DIM = 384
|
| 27 |
+
|
| 28 |
+
# ImageNet statistics — what DINOv2 was trained with.
|
| 29 |
+
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 30 |
+
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 31 |
+
|
| 32 |
+
_lock = threading.Lock()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _model_path() -> str:
|
| 36 |
+
"""Download the full-precision ONNX weights, skipping quantised variants.
|
| 37 |
+
|
| 38 |
+
Quantised weights would halve memory but shift every cosine similarity
|
| 39 |
+
slightly, which moves the thresholds we cross-validated. Not worth it at
|
| 40 |
+
~90 MB on a 16 GB box.
|
| 41 |
+
"""
|
| 42 |
+
from huggingface_hub import hf_hub_download, list_repo_files
|
| 43 |
+
|
| 44 |
+
onnx = [f for f in list_repo_files(MODEL_REPO) if f.endswith(".onnx")]
|
| 45 |
+
full = [
|
| 46 |
+
f
|
| 47 |
+
for f in onnx
|
| 48 |
+
if not any(q in f.lower() for q in ("quant", "int8", "uint8", "q4", "fp16", "bnb"))
|
| 49 |
+
]
|
| 50 |
+
return hf_hub_download(MODEL_REPO, sorted(full or onnx, key=len)[0])
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@functools.lru_cache(maxsize=1)
|
| 54 |
+
def _session():
|
| 55 |
+
import onnxruntime as ort
|
| 56 |
+
|
| 57 |
+
opts = ort.SessionOptions()
|
| 58 |
+
# Free Spaces gives 2 vCPUs. Letting ORT spawn more threads than that makes
|
| 59 |
+
# it slower, not faster.
|
| 60 |
+
opts.intra_op_num_threads = 2
|
| 61 |
+
opts.inter_op_num_threads = 1
|
| 62 |
+
return ort.InferenceSession(
|
| 63 |
+
_model_path(), sess_options=opts, providers=["CPUExecutionProvider"]
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def warm() -> None:
|
| 68 |
+
"""Force model download + graph load at startup rather than on first request."""
|
| 69 |
+
sess = _session()
|
| 70 |
+
name = sess.get_inputs()[0].name
|
| 71 |
+
sess.run(None, {name: np.zeros((1, 3, IMG_SIZE, IMG_SIZE), dtype=np.float32)})
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def preprocess(img: Image.Image, watermark_crop: float = 0.0) -> np.ndarray:
|
| 75 |
+
img = img.convert("RGB")
|
| 76 |
+
if watermark_crop:
|
| 77 |
+
w, h = img.size
|
| 78 |
+
img = img.crop((0, 0, w, int(h * (1 - watermark_crop))))
|
| 79 |
+
|
| 80 |
+
# Resize the WHOLE frame rather than centre-cropping. For the wide shot the
|
| 81 |
+
# surroundings are a large part of the signal — the spike showed matching
|
| 82 |
+
# works partly off the background — and a centre crop throws exactly that
|
| 83 |
+
# away.
|
| 84 |
+
img = img.resize((IMG_SIZE, IMG_SIZE), Image.BICUBIC)
|
| 85 |
+
a = np.asarray(img, dtype=np.float32) / 255.0
|
| 86 |
+
return ((a - _MEAN) / _STD).transpose(2, 0, 1)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def embed(img: Image.Image) -> np.ndarray:
|
| 90 |
+
"""L2-normalised 384-d CLS embedding. Dot product of two of these is cosine."""
|
| 91 |
+
x = preprocess(img)[None, ...]
|
| 92 |
+
with _lock: # ORT sessions are not guaranteed thread-safe for concurrent run()
|
| 93 |
+
out = _session().run(None, {_session().get_inputs()[0].name: x})[0]
|
| 94 |
+
v = out[0, 0].astype(np.float32) # CLS token summarises the image
|
| 95 |
+
return v / (np.linalg.norm(v) + 1e-9)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def cosine(a: np.ndarray, b: np.ndarray) -> float:
|
| 99 |
+
return float(np.dot(a, b))
|
greenproof_ml/pipeline.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scoring one check-in, end to end.
|
| 2 |
+
|
| 3 |
+
fetch row -> download both photos -> gates -> scores -> write back
|
| 4 |
+
|
| 5 |
+
Idempotent: re-running on the same check-in recomputes from the stored photos
|
| 6 |
+
and overwrites the result. That matters more than it sounds, because it means
|
| 7 |
+
every threshold change can be replayed over the entire pilot dataset without
|
| 8 |
+
going back into the field. The photos are the raw evidence; the verdict is
|
| 9 |
+
derived and disposable.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import random
|
| 16 |
+
|
| 17 |
+
from . import store
|
| 18 |
+
from .embed import cosine, embed
|
| 19 |
+
from .scoring import (
|
| 20 |
+
Assessment,
|
| 21 |
+
assess,
|
| 22 |
+
PROXIMITY_REVIEW_M,
|
| 23 |
+
check_plant_plausibility,
|
| 24 |
+
check_proximity,
|
| 25 |
+
gate_duplicate,
|
| 26 |
+
gate_gps,
|
| 27 |
+
gate_travel,
|
| 28 |
+
score_growth,
|
| 29 |
+
score_liveness,
|
| 30 |
+
score_location,
|
| 31 |
+
score_scene,
|
| 32 |
+
)
|
| 33 |
+
from .signals import (
|
| 34 |
+
canopy_fraction,
|
| 35 |
+
excess_green,
|
| 36 |
+
haversine_m,
|
| 37 |
+
liveness_score,
|
| 38 |
+
orb_inliers,
|
| 39 |
+
phash,
|
| 40 |
+
plant_reference,
|
| 41 |
+
trunk_width_mm,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
log = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
# Fraction of auto-approved check-ins pulled for human spot-checking.
|
| 47 |
+
# Without this we would have no way to estimate the false-ACCEPT rate, which is
|
| 48 |
+
# the number that actually matters — a wrongly rejected genuine visit is
|
| 49 |
+
# annoying, a wrongly approved fraud is fatal, and nothing else in the system
|
| 50 |
+
# measures the second one.
|
| 51 |
+
AUDIT_RATE = 0.10
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def score_checkin(checkin_id: str) -> Assessment:
|
| 55 |
+
row = store.get_checkin(checkin_id)
|
| 56 |
+
if row is None:
|
| 57 |
+
raise LookupError(f"No check-in {checkin_id}")
|
| 58 |
+
|
| 59 |
+
tree = store.get_tree(row["tree_id"])
|
| 60 |
+
if tree is None:
|
| 61 |
+
raise LookupError(f"Check-in {checkin_id} references a missing tree")
|
| 62 |
+
|
| 63 |
+
wide = store.download_image(row["wide_photo"])
|
| 64 |
+
close = store.download_image(row["close_photo"])
|
| 65 |
+
|
| 66 |
+
# --- fingerprints ------------------------------------------------------
|
| 67 |
+
wide_ph = phash(wide)
|
| 68 |
+
wide_emb = embed(wide)
|
| 69 |
+
close_emb = embed(close)
|
| 70 |
+
|
| 71 |
+
captured_at = store.parse_ts(row["captured_at"])
|
| 72 |
+
prev_visits = store.previous_checkins(row["tree_id"], row["captured_at"])
|
| 73 |
+
|
| 74 |
+
# --- gates -------------------------------------------------------------
|
| 75 |
+
gates = {}
|
| 76 |
+
|
| 77 |
+
gates["duplicate_image"] = gate_duplicate(
|
| 78 |
+
wide_ph, store.other_phashes(checkin_id)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
gates["gps_radius"] = gate_gps(
|
| 82 |
+
tree["lat"],
|
| 83 |
+
tree["lng"],
|
| 84 |
+
row["lat"],
|
| 85 |
+
row["lng"],
|
| 86 |
+
row.get("gps_accuracy_m"),
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
prev_any = store.previous_by_submitter(
|
| 90 |
+
row["submitted_by"], row["captured_at"], checkin_id
|
| 91 |
+
)
|
| 92 |
+
gates["travel_speed"] = gate_travel(
|
| 93 |
+
row["lat"],
|
| 94 |
+
row["lng"],
|
| 95 |
+
captured_at,
|
| 96 |
+
(
|
| 97 |
+
prev_any["id"],
|
| 98 |
+
prev_any["lat"],
|
| 99 |
+
prev_any["lng"],
|
| 100 |
+
store.parse_ts(prev_any["captured_at"]),
|
| 101 |
+
)
|
| 102 |
+
if prev_any
|
| 103 |
+
else None,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# --- scores ------------------------------------------------------------
|
| 107 |
+
scores: dict[str, dict] = {}
|
| 108 |
+
|
| 109 |
+
distance = haversine_m(tree["lat"], tree["lng"], row["lat"], row["lng"])
|
| 110 |
+
scores["location"] = score_location(distance)
|
| 111 |
+
|
| 112 |
+
green = excess_green(wide)
|
| 113 |
+
scores["liveness"] = score_liveness(green, liveness_score(green))
|
| 114 |
+
|
| 115 |
+
# Scene match needs something to match AGAINST. On the very first visit
|
| 116 |
+
# there is nothing, so the signal is absent rather than zero — assess()
|
| 117 |
+
# renormalises and lowers confidence via the coverage term instead of
|
| 118 |
+
# treating a registration as suspicious.
|
| 119 |
+
if prev_visits:
|
| 120 |
+
best_wide = best_close = None
|
| 121 |
+
for p in prev_visits:
|
| 122 |
+
pw = store.parse_embedding(p.get("wide_embedding"))
|
| 123 |
+
pc = store.parse_embedding(p.get("close_embedding"))
|
| 124 |
+
if pw is not None:
|
| 125 |
+
c = cosine(wide_emb, pw)
|
| 126 |
+
best_wide = c if best_wide is None else max(best_wide, c)
|
| 127 |
+
if pc is not None:
|
| 128 |
+
c = cosine(close_emb, pc)
|
| 129 |
+
best_close = c if best_close is None else max(best_close, c)
|
| 130 |
+
|
| 131 |
+
inliers = None
|
| 132 |
+
try:
|
| 133 |
+
prev_wide = store.download_image(prev_visits[0]["wide_photo"])
|
| 134 |
+
inliers = orb_inliers(wide, prev_wide)
|
| 135 |
+
except Exception: # noqa: BLE001 - corroboration only, never fatal
|
| 136 |
+
log.warning("ORB comparison failed for %s", checkin_id, exc_info=True)
|
| 137 |
+
|
| 138 |
+
if best_wide is not None or best_close is not None:
|
| 139 |
+
scores["scene_match"] = score_scene(best_wide, best_close, inliers)
|
| 140 |
+
|
| 141 |
+
canopy_now = canopy_fraction(wide)
|
| 142 |
+
canopy_prev = trunk_prev = None
|
| 143 |
+
if prev_visits:
|
| 144 |
+
prev_growth = ((prev_visits[0].get("signals") or {}).get("scores") or {}).get(
|
| 145 |
+
"growth", {}
|
| 146 |
+
)
|
| 147 |
+
canopy_prev = prev_growth.get("canopy_frac")
|
| 148 |
+
trunk_prev = prev_growth.get("trunk_mm")
|
| 149 |
+
|
| 150 |
+
# Millimetres are derived HERE, from the stored photo and the stored taps —
|
| 151 |
+
# never from anything the client calculated. See signals.trunk_width_mm.
|
| 152 |
+
measurement = trunk_width_mm(close, row.get("trunk_taps"))
|
| 153 |
+
trunk_now = measurement.get("trunk_mm") if measurement and measurement.get("available") else None
|
| 154 |
+
|
| 155 |
+
scores["growth"] = score_growth(canopy_now, canopy_prev, trunk_now, trunk_prev)
|
| 156 |
+
if measurement:
|
| 157 |
+
scores["growth"]["measurement"] = measurement
|
| 158 |
+
|
| 159 |
+
# --- review flags ------------------------------------------------------
|
| 160 |
+
# Not gates: these must not auto-approve, but they are not accusations. See
|
| 161 |
+
# scoring.check_proximity for why proximity cannot be a gate.
|
| 162 |
+
review_flags = {}
|
| 163 |
+
|
| 164 |
+
# Runs on EVERY visit, not just the first. Registering a chair is the
|
| 165 |
+
# obvious case, but swapping one in at visit three is the case a
|
| 166 |
+
# first-visit-only check would wave through.
|
| 167 |
+
review_flags["plant_plausibility"] = check_plant_plausibility(
|
| 168 |
+
wide_emb, close_emb, plant_reference()
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# Only on the tree's FIRST check-in, i.e. its registration. On every later
|
| 172 |
+
# visit the tree already exists and standing near a neighbour is not news —
|
| 173 |
+
# re-flagging it every round would bury the reviewer in the same alert.
|
| 174 |
+
if not prev_visits:
|
| 175 |
+
review_flags["tree_proximity"] = check_proximity(
|
| 176 |
+
tree["lat"],
|
| 177 |
+
tree["lng"],
|
| 178 |
+
store.trees_near_registered_before(
|
| 179 |
+
tree["id"],
|
| 180 |
+
tree["lat"],
|
| 181 |
+
tree["lng"],
|
| 182 |
+
tree["created_at"],
|
| 183 |
+
PROXIMITY_REVIEW_M,
|
| 184 |
+
),
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# --- combine and persist ----------------------------------------------
|
| 188 |
+
result = assess(gates, scores, review_flags=review_flags)
|
| 189 |
+
|
| 190 |
+
# Carry provenance forward. write_result replaces `signals` WHOLESALE, so a
|
| 191 |
+
# self-test tag written at insert time is destroyed by the very act of
|
| 192 |
+
# scoring. That is not cosmetic: it made six injected attack rows
|
| 193 |
+
# indistinguishable from real pilot data, and the only thing that still
|
| 194 |
+
# identified them was the storage path. Provenance is a fact about where a
|
| 195 |
+
# row came from, not part of the score, and must survive being scored.
|
| 196 |
+
provenance = (row.get("signals") or {}).get("self_test")
|
| 197 |
+
if provenance:
|
| 198 |
+
result.signals["self_test"] = provenance
|
| 199 |
+
|
| 200 |
+
audit = result.verdict == "verified" and random.random() < AUDIT_RATE
|
| 201 |
+
|
| 202 |
+
store.write_result(
|
| 203 |
+
checkin_id,
|
| 204 |
+
phash=wide_ph,
|
| 205 |
+
wide_embedding=wide_emb,
|
| 206 |
+
close_embedding=close_emb,
|
| 207 |
+
confidence=result.confidence,
|
| 208 |
+
verdict=result.verdict,
|
| 209 |
+
signals=result.signals,
|
| 210 |
+
audit_sample=audit,
|
| 211 |
+
)
|
| 212 |
+
store.update_tree_status(row["tree_id"], result.confidence, result.verdict)
|
| 213 |
+
|
| 214 |
+
log.info(
|
| 215 |
+
"scored %s -> %s (%d) gates=%s",
|
| 216 |
+
checkin_id,
|
| 217 |
+
result.verdict,
|
| 218 |
+
result.confidence,
|
| 219 |
+
{k: v.passed for k, v in gates.items()},
|
| 220 |
+
)
|
| 221 |
+
return result
|
greenproof_ml/plant_reference.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3de6378a6d4c5dc8f1543a0f51e602bb1184ab69b71bc0fd69f742e542c026dc
|
| 3 |
+
size 113792
|
greenproof_ml/scoring.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Combining the detectors into a confidence and a verdict.
|
| 2 |
+
|
| 3 |
+
TWO-STAGE BY DESIGN.
|
| 4 |
+
|
| 5 |
+
Stage 1 — GATES. Disqualifying on their own, regardless of anything else.
|
| 6 |
+
A duplicate photo is a duplicate photo; no amount of scene
|
| 7 |
+
similarity should rescue it. Gates run BEFORE scoring so a
|
| 8 |
+
failure is cheap and unambiguous.
|
| 9 |
+
|
| 10 |
+
Stage 2 — SCORES. Continuous 0..1 signals, combined by weights, producing a
|
| 11 |
+
confidence out of 100.
|
| 12 |
+
|
| 13 |
+
This ordering is the reason the system's overall accuracy is far better than the
|
| 14 |
+
54% rank-1 of image matching alone: five of the six fraud types in the attack set
|
| 15 |
+
never reach stage 2.
|
| 16 |
+
|
| 17 |
+
THRESHOLDS AND WEIGHTS BELOW ARE PROVISIONAL. They are informed guesses that let
|
| 18 |
+
the pipeline run end-to-end from day one. They are replaced by weights fitted
|
| 19 |
+
with logistic regression under leave-one-tree-out cross-validation once real
|
| 20 |
+
check-in rounds exist — see calibrate.py. Reporting a number produced by
|
| 21 |
+
hand-tuned weights on the same data would be exactly the mistake CLAUDE.md
|
| 22 |
+
forbids.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from datetime import datetime
|
| 29 |
+
|
| 30 |
+
from .signals import DUPLICATE_MAX_HAMMING, haversine_m, hamming
|
| 31 |
+
|
| 32 |
+
# --- gate parameters -------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
# How far from the registered point a check-in may be.
|
| 35 |
+
# Built from the GPS accuracy of BOTH readings plus a slack term, rather than a
|
| 36 |
+
# flat radius: a ±3 m fix and a ±18 m fix genuinely deserve different tolerances,
|
| 37 |
+
# and a flat number would either reject good check-ins under canopy or wave
|
| 38 |
+
# through bad ones in the open.
|
| 39 |
+
GPS_SLACK_M = 15.0
|
| 40 |
+
GPS_MAX_RADIUS_M = 60.0
|
| 41 |
+
|
| 42 |
+
# Nobody walks a plantation at 120 km/h. Catches one account submitting
|
| 43 |
+
# check-ins from two places faster than a human could travel between them.
|
| 44 |
+
MAX_TRAVEL_KMH = 120.0
|
| 45 |
+
|
| 46 |
+
# Two TREES registered closer together than this are possibly one tree entered
|
| 47 |
+
# twice — the double-payment case.
|
| 48 |
+
#
|
| 49 |
+
# NOT A GATE, DELIBERATELY. A failed gate means confidence 0 and 'flagged', and
|
| 50 |
+
# trees genuinely do grow 3 m apart: flagging a dense grove as fraud would be
|
| 51 |
+
# both wrong and ruinous to the pilot numbers. Phone GPS at 5-10 m simply cannot
|
| 52 |
+
# tell "one tree measured twice" from "two neighbouring trees", so the honest
|
| 53 |
+
# response is a human look, not an accusation. It routes to REVIEW instead.
|
| 54 |
+
#
|
| 55 |
+
# PROVISIONAL. Calibrate against the pilot's nearest-neighbour distance
|
| 56 |
+
# distribution and set it below the 5th percentile — see tools/proximity_audit.py.
|
| 57 |
+
# Must stay in step with PROXIMITY_WARN_M in app/src/lib/trees.ts, which is the
|
| 58 |
+
# client-side warning for the same condition.
|
| 59 |
+
PROXIMITY_REVIEW_M = 8.0
|
| 60 |
+
|
| 61 |
+
# Cosine similarity to the nearest known plant, below which a photo does not
|
| 62 |
+
# look like a plant at all.
|
| 63 |
+
#
|
| 64 |
+
# THE FIRST TWO NUMBERS HERE WERE WRONG AND THE STORY IS THE POINT.
|
| 65 |
+
#
|
| 66 |
+
# Held-out spike photos against 300 COCO photographs scored AUC 1.000 with a
|
| 67 |
+
# threshold of 0.519 and an 8.1% false-reject rate. Applied to the 9 REAL pilot
|
| 68 |
+
# check-ins - different plants, different site - that same threshold flagged
|
| 69 |
+
# FIVE OF NINE. The holdout was other angles of the SAME 15 plants at the SAME
|
| 70 |
+
# shoot, so it had been measuring "was this taken at that shoot", not "is this
|
| 71 |
+
# a plant". An AUC of 1.000 is a warning, not a result.
|
| 72 |
+
#
|
| 73 |
+
# Re-measured against genuinely unseen trees:
|
| 74 |
+
#
|
| 75 |
+
# AUC (real pilot photos vs 300 COCO) 0.966
|
| 76 |
+
#
|
| 77 |
+
# threshold check-ins flagged non-plants accepted
|
| 78 |
+
# 0.250 1/9 (11%) 1.3%
|
| 79 |
+
# 0.300 1/9 (11%) 1.0% <- chosen
|
| 80 |
+
# 0.350 2/9 (22%) 0.3%
|
| 81 |
+
# 0.519 5/9 (56%) 0.0%
|
| 82 |
+
#
|
| 83 |
+
# 0.30 buys 99% of non-plants for one check-in in nine going to a human. Pushing
|
| 84 |
+
# higher trades real planters' time for a rounding error of extra safety, and a
|
| 85 |
+
# non-plant that slips still faces GPS, duplicate and scene-match.
|
| 86 |
+
#
|
| 87 |
+
# STILL PROVISIONAL: measured on 18 photos from 6 trees at one site. The honest
|
| 88 |
+
# fix is not a better threshold, it is a bigger reference set - every verified
|
| 89 |
+
# check-in is a known-good plant photo, so this improves as the system runs.
|
| 90 |
+
PLANT_SIMILARITY_MIN = 0.30
|
| 91 |
+
|
| 92 |
+
# --- score shaping ---------------------------------------------------------
|
| 93 |
+
|
| 94 |
+
LOCATION_FULL_M = 10.0 # at or under this, full marks
|
| 95 |
+
LOCATION_ZERO_M = 60.0 # at or over this, nothing
|
| 96 |
+
|
| 97 |
+
SCENE_COSINE_FLOOR = 0.35 # below this, no evidence of the same scene
|
| 98 |
+
SCENE_COSINE_CEIL = 0.75 # at or above, as good as same-scene gets
|
| 99 |
+
ORB_INLIER_STRONG = 25 # inliers that would count as strong agreement
|
| 100 |
+
|
| 101 |
+
# ORB/RANSAC IS MEASURED AND DISABLED. Do not switch this on without re-running
|
| 102 |
+
# the measurement.
|
| 103 |
+
#
|
| 104 |
+
# Tested on the 59 spike photos, 287 pairs (87 same-tree, 200 different-tree):
|
| 105 |
+
#
|
| 106 |
+
# same tree 63.2% zero inliers, mean 1.7, max 8
|
| 107 |
+
# different tree 75.0% zero inliers, mean 1.1, max 6
|
| 108 |
+
#
|
| 109 |
+
# The distributions overlap almost entirely. ORB finds no consistent geometry
|
| 110 |
+
# between two genuine photos of the same tree taken minutes apart from slightly
|
| 111 |
+
# different positions — foliage moves, and bark at two scales shares almost no
|
| 112 |
+
# repeatable keypoints. Leaving it in the score would have added up to +0.08 of
|
| 113 |
+
# essentially random confidence, which helps an impostor as readily as a genuine
|
| 114 |
+
# visit.
|
| 115 |
+
#
|
| 116 |
+
# It stays COMPUTED and recorded in `signals` so the decision can be revisited
|
| 117 |
+
# with real data, because the spike had no framing control. The ghost overlay
|
| 118 |
+
# and compass heading exist precisely to reproduce framing, and ORB may become
|
| 119 |
+
# viable once check-in round 1 produces aligned pairs. Re-measure then; turn it
|
| 120 |
+
# on only if the same/different distributions actually separate.
|
| 121 |
+
ORB_CONTRIBUTES_TO_SCORE = False
|
| 122 |
+
|
| 123 |
+
# --- combination -----------------------------------------------------------
|
| 124 |
+
|
| 125 |
+
# Deliberately NOT equal. Image matching is the weakest signal we measured
|
| 126 |
+
# (rank-1 54%, AUC 0.77, and bimodal), so it must not dominate. Location and
|
| 127 |
+
# liveness are cheap, deterministic and reliable — they carry more.
|
| 128 |
+
DEFAULT_WEIGHTS: dict[str, float] = {
|
| 129 |
+
"location": 0.35,
|
| 130 |
+
"liveness": 0.30,
|
| 131 |
+
"scene_match": 0.25,
|
| 132 |
+
"growth": 0.10,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
VERIFY_AT = 70
|
| 136 |
+
REVIEW_AT = 45
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------------------
|
| 139 |
+
# CRITICAL FLOORS — the fix for a false accept found in testing
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
#
|
| 142 |
+
# A plain weighted sum lets strong signals COMPENSATE for a failed one. Tested
|
| 143 |
+
# against "a different tree photographed at the correct GPS" — attack #4, the
|
| 144 |
+
# one case that genuinely needs the AI — the result was:
|
| 145 |
+
#
|
| 146 |
+
# scene_match 0.00, location 1.00, liveness 1.00, growth 1.00
|
| 147 |
+
# -> 75/100 -> VERIFIED
|
| 148 |
+
#
|
| 149 |
+
# The system auto-approved the exact fraud it exists to catch, because
|
| 150 |
+
# scene_match carries only 0.25 of the weight and the other three were perfect.
|
| 151 |
+
# Compensation is correct for a quality score and wrong for a fraud decision.
|
| 152 |
+
#
|
| 153 |
+
# So a signal that is present and near-zero VETOES auto-approval. It does not
|
| 154 |
+
# flag the visit — it routes it to a human, which is the honest response to
|
| 155 |
+
# "this looks wrong but we are not certain". Confidence is still reported
|
| 156 |
+
# unchanged so the disagreement stays visible in the data.
|
| 157 |
+
#
|
| 158 |
+
# Floors are set BELOW the genuine weak case and ABOVE the fraud case, measured:
|
| 159 |
+
# dense same-species stand, genuine -> scene_match ~0.19 (must still pass)
|
| 160 |
+
# different tree entirely -> scene_match 0.00 (must be capped)
|
| 161 |
+
CRITICAL_FLOORS: dict[str, float] = {
|
| 162 |
+
# RAISED FROM 0.20 AFTER MEASUREMENT. A photograph of a brown wooden chair
|
| 163 |
+
# scored 0.218 and cleared the old floor by 0.018.
|
| 164 |
+
#
|
| 165 |
+
# On the 9 real pilot WIDE shots the distribution is bimodal - either 0.000
|
| 166 |
+
# or >= 0.557, with nothing in between - so 0.35 sits in an empty gap and
|
| 167 |
+
# rejects no real photo we have, while putting clear daylight above the
|
| 168 |
+
# chair.
|
| 169 |
+
#
|
| 170 |
+
# THIS DOES NOT SOLVE THE CHAIR PROBLEM and must not be described as if it
|
| 171 |
+
# does. It only catches BROWN fakes. A green wall still scores 1.00, which
|
| 172 |
+
# is what check_plant_plausibility exists for. Sample is 9 wide shots, so
|
| 173 |
+
# the "rejects nothing real" claim is thin - revisit with more rounds.
|
| 174 |
+
"liveness": 0.35,
|
| 175 |
+
"scene_match": 0.12,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
# Auto-approval also requires enough of the signal set to have been available.
|
| 179 |
+
# One signal out of four is not a verification, however good that signal is.
|
| 180 |
+
MIN_COVERAGE_TO_VERIFY = 0.50
|
| 181 |
+
|
| 182 |
+
MODEL_NAME = "dinov2-small-onnx"
|
| 183 |
+
MODEL_VERSION = "2026.08.1"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@dataclass
|
| 187 |
+
class GateResult:
|
| 188 |
+
passed: bool
|
| 189 |
+
detail: dict
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@dataclass
|
| 193 |
+
class Assessment:
|
| 194 |
+
confidence: int
|
| 195 |
+
verdict: str
|
| 196 |
+
signals: dict = field(default_factory=dict)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _ramp(value: float, full: float, zero: float) -> float:
|
| 200 |
+
"""1.0 at `full`, 0.0 at `zero`, linear between. Handles either direction."""
|
| 201 |
+
if full == zero:
|
| 202 |
+
return 1.0 if value <= full else 0.0
|
| 203 |
+
t = (value - zero) / (full - zero)
|
| 204 |
+
return float(min(1.0, max(0.0, t)))
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ---------------------------------------------------------------------------
|
| 208 |
+
# Gates
|
| 209 |
+
# ---------------------------------------------------------------------------
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def gate_duplicate(this_phash: str, others: list[tuple[str, str]]) -> GateResult:
|
| 213 |
+
"""others: (checkin_id, phash) for every OTHER check-in in the system."""
|
| 214 |
+
nearest_id, nearest = None, 64
|
| 215 |
+
for cid, ph in others:
|
| 216 |
+
if not ph:
|
| 217 |
+
continue
|
| 218 |
+
d = hamming(this_phash, ph)
|
| 219 |
+
if d < nearest:
|
| 220 |
+
nearest, nearest_id = d, cid
|
| 221 |
+
|
| 222 |
+
passed = nearest > DUPLICATE_MAX_HAMMING
|
| 223 |
+
return GateResult(
|
| 224 |
+
passed,
|
| 225 |
+
{
|
| 226 |
+
"passed": passed,
|
| 227 |
+
"nearest_phash_distance": nearest,
|
| 228 |
+
**({"matched_checkin": nearest_id} if not passed and nearest_id else {}),
|
| 229 |
+
},
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def check_plant_plausibility(
|
| 234 |
+
wide_emb,
|
| 235 |
+
close_emb,
|
| 236 |
+
reference,
|
| 237 |
+
) -> GateResult:
|
| 238 |
+
"""Does either photo fail to look like a plant?
|
| 239 |
+
|
| 240 |
+
Stops a chair, a wall or a green bedsheet being registered as a tree -
|
| 241 |
+
which the liveness signal does not, because a green bedsheet is
|
| 242 |
+
vegetation-coloured and scores a perfect 1.00.
|
| 243 |
+
|
| 244 |
+
BOTH photos are checked and the WORSE one decides, so swapping a real
|
| 245 |
+
tree's wide shot in front of a close-up of something else does not pass.
|
| 246 |
+
|
| 247 |
+
Reference vectors are unit-normalised, so a dot product IS the cosine.
|
| 248 |
+
A missing reference set passes: degrade, don't break.
|
| 249 |
+
"""
|
| 250 |
+
if reference is None or wide_emb is None or close_emb is None:
|
| 251 |
+
return GateResult(True, {"passed": True, "available": False})
|
| 252 |
+
|
| 253 |
+
sw = float(reference.dot(wide_emb).max())
|
| 254 |
+
sc = float(reference.dot(close_emb).max())
|
| 255 |
+
worst = min(sw, sc)
|
| 256 |
+
passed = worst >= PLANT_SIMILARITY_MIN
|
| 257 |
+
return GateResult(
|
| 258 |
+
passed,
|
| 259 |
+
{
|
| 260 |
+
"passed": passed,
|
| 261 |
+
"available": True,
|
| 262 |
+
"wide_similarity": round(sw, 3),
|
| 263 |
+
"close_similarity": round(sc, 3),
|
| 264 |
+
"threshold": PLANT_SIMILARITY_MIN,
|
| 265 |
+
},
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def check_proximity(
|
| 270 |
+
lat: float,
|
| 271 |
+
lng: float,
|
| 272 |
+
earlier: list[tuple[str, float, float]],
|
| 273 |
+
) -> GateResult:
|
| 274 |
+
"""Is there already a tree registered at this spot?
|
| 275 |
+
|
| 276 |
+
`earlier` is (tree_id, lat, lng) for trees registered STRICTLY BEFORE this
|
| 277 |
+
one. The ordering matters: when one trunk becomes two rows, only the second
|
| 278 |
+
row is the problem. Comparing against all trees would retroactively taint
|
| 279 |
+
the original — and the original is the one with the honest photo history.
|
| 280 |
+
|
| 281 |
+
Returns a GateResult for shape consistency with the real gates, but it is
|
| 282 |
+
passed to assess() as a REVIEW FLAG, not a gate. `passed=False` here means
|
| 283 |
+
"a person should look", never "this is fraud".
|
| 284 |
+
"""
|
| 285 |
+
nearest_id, nearest = None, float("inf")
|
| 286 |
+
for tid, tlat, tlng in earlier:
|
| 287 |
+
d = haversine_m(lat, lng, tlat, tlng)
|
| 288 |
+
if d < nearest:
|
| 289 |
+
nearest, nearest_id = d, tid
|
| 290 |
+
|
| 291 |
+
passed = nearest > PROXIMITY_REVIEW_M
|
| 292 |
+
detail: dict = {"passed": passed, "radius_m": PROXIMITY_REVIEW_M}
|
| 293 |
+
if nearest_id is not None:
|
| 294 |
+
detail["nearest_tree"] = nearest_id
|
| 295 |
+
detail["nearest_distance_m"] = round(nearest, 1)
|
| 296 |
+
return GateResult(passed, detail)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def gate_gps(
|
| 300 |
+
tree_lat: float,
|
| 301 |
+
tree_lng: float,
|
| 302 |
+
lat: float,
|
| 303 |
+
lng: float,
|
| 304 |
+
accuracy_m: float | None,
|
| 305 |
+
registration_accuracy_m: float | None = None,
|
| 306 |
+
) -> GateResult:
|
| 307 |
+
dist = haversine_m(tree_lat, tree_lng, lat, lng)
|
| 308 |
+
allowed = min(
|
| 309 |
+
GPS_MAX_RADIUS_M,
|
| 310 |
+
GPS_SLACK_M + (accuracy_m or 0.0) + (registration_accuracy_m or 0.0),
|
| 311 |
+
)
|
| 312 |
+
passed = dist <= allowed
|
| 313 |
+
return GateResult(
|
| 314 |
+
passed,
|
| 315 |
+
{"passed": passed, "distance_m": round(dist, 1), "allowed_m": round(allowed, 1)},
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def gate_travel(
|
| 320 |
+
lat: float,
|
| 321 |
+
lng: float,
|
| 322 |
+
captured_at: datetime,
|
| 323 |
+
previous: tuple[str, float, float, datetime] | None,
|
| 324 |
+
) -> GateResult:
|
| 325 |
+
"""previous: the same submitter's most recent check-in elsewhere."""
|
| 326 |
+
if previous is None:
|
| 327 |
+
return GateResult(True, {"passed": True})
|
| 328 |
+
|
| 329 |
+
pid, plat, plng, pat = previous
|
| 330 |
+
seconds = abs((captured_at - pat).total_seconds())
|
| 331 |
+
if seconds < 1:
|
| 332 |
+
# Two check-ins at the same instant from different places is itself
|
| 333 |
+
# impossible; treat as a failure rather than dividing by ~zero.
|
| 334 |
+
metres = haversine_m(plat, plng, lat, lng)
|
| 335 |
+
passed = metres < 50
|
| 336 |
+
return GateResult(passed, {"passed": passed, "kmh": None, "from_checkin": pid})
|
| 337 |
+
|
| 338 |
+
kmh = (haversine_m(plat, plng, lat, lng) / seconds) * 3.6
|
| 339 |
+
passed = kmh <= MAX_TRAVEL_KMH
|
| 340 |
+
return GateResult(
|
| 341 |
+
passed, {"passed": passed, "kmh": round(kmh, 1), "from_checkin": pid}
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# ---------------------------------------------------------------------------
|
| 346 |
+
# Scores
|
| 347 |
+
# ---------------------------------------------------------------------------
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def score_location(distance_m: float) -> dict:
|
| 351 |
+
return {
|
| 352 |
+
"score": _ramp(distance_m, LOCATION_FULL_M, LOCATION_ZERO_M),
|
| 353 |
+
"distance_m": round(distance_m, 1),
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def score_liveness(green_fraction: float, mapped: float) -> dict:
|
| 358 |
+
return {"score": mapped, "excess_green": round(green_fraction, 4)}
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def score_scene(
|
| 362 |
+
cosine_wide: float | None,
|
| 363 |
+
cosine_close: float | None,
|
| 364 |
+
inliers: int | None,
|
| 365 |
+
) -> dict:
|
| 366 |
+
"""Corroboration, never identity.
|
| 367 |
+
|
| 368 |
+
The wide shot is weighted above the close-up because the spike showed
|
| 369 |
+
matching works substantially off the surrounding scene, and young bark is
|
| 370 |
+
smooth and far less distinctive than mature bark — BarkNet's ~94% figures
|
| 371 |
+
are on MATURE bark and do not transfer to a two-year-old sapling.
|
| 372 |
+
|
| 373 |
+
ORB inliers are RECORDED BUT DO NOT AFFECT THE SCORE — see
|
| 374 |
+
ORB_CONTRIBUTES_TO_SCORE above for the measurement that produced that
|
| 375 |
+
decision. Reporting a signal we have shown to be non-discriminating would be
|
| 376 |
+
exactly the unfounded assertion this project exists to avoid.
|
| 377 |
+
"""
|
| 378 |
+
parts: list[tuple[float, float]] = []
|
| 379 |
+
if cosine_wide is not None:
|
| 380 |
+
parts.append((_ramp(-cosine_wide, -SCENE_COSINE_CEIL, -SCENE_COSINE_FLOOR), 0.6))
|
| 381 |
+
if cosine_close is not None:
|
| 382 |
+
parts.append((_ramp(-cosine_close, -SCENE_COSINE_CEIL, -SCENE_COSINE_FLOOR), 0.4))
|
| 383 |
+
|
| 384 |
+
base = (
|
| 385 |
+
sum(v * w for v, w in parts) / sum(w for _, w in parts) if parts else 0.0
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
if inliers and ORB_CONTRIBUTES_TO_SCORE:
|
| 389 |
+
base = min(1.0, base + 0.25 * min(1.0, inliers / ORB_INLIER_STRONG))
|
| 390 |
+
|
| 391 |
+
out: dict = {"score": round(base, 4)}
|
| 392 |
+
if cosine_wide is not None:
|
| 393 |
+
out["cosine_wide"] = round(cosine_wide, 4)
|
| 394 |
+
if cosine_close is not None:
|
| 395 |
+
out["cosine_close"] = round(cosine_close, 4)
|
| 396 |
+
if inliers is not None:
|
| 397 |
+
out["orb_inliers"] = int(inliers)
|
| 398 |
+
return out
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def score_growth(
|
| 402 |
+
canopy_now: float | None,
|
| 403 |
+
canopy_prev: float | None,
|
| 404 |
+
trunk_mm: float | None = None,
|
| 405 |
+
trunk_prev_mm: float | None = None,
|
| 406 |
+
) -> dict:
|
| 407 |
+
"""Plausibility, not measurement.
|
| 408 |
+
|
| 409 |
+
Trunk diameter barely moves in four weeks on a young tree, so this signal
|
| 410 |
+
cannot demonstrate growth and we do not claim it does. Its job is catching
|
| 411 |
+
the IMPOSSIBLE: a trunk that shrank, a seedling that became a mature tree
|
| 412 |
+
overnight, a canopy that vanished.
|
| 413 |
+
|
| 414 |
+
With no prior visit there is nothing to compare, so it returns a neutral 0.5
|
| 415 |
+
rather than 0 — a first check-in must not be penalised for being first.
|
| 416 |
+
"""
|
| 417 |
+
out: dict = {}
|
| 418 |
+
if canopy_now is not None:
|
| 419 |
+
out["canopy_frac"] = round(canopy_now, 4)
|
| 420 |
+
if trunk_mm is not None:
|
| 421 |
+
out["trunk_mm"] = round(trunk_mm, 1)
|
| 422 |
+
|
| 423 |
+
if canopy_prev is None and trunk_prev_mm is None:
|
| 424 |
+
out["score"] = 0.5
|
| 425 |
+
return out
|
| 426 |
+
|
| 427 |
+
score = 1.0
|
| 428 |
+
|
| 429 |
+
if canopy_now is not None and canopy_prev is not None:
|
| 430 |
+
delta = canopy_now - canopy_prev
|
| 431 |
+
# Losing more than half the canopy in one interval is the dying-tree
|
| 432 |
+
# signal. Real, and exactly what we want surfaced for review.
|
| 433 |
+
if canopy_prev > 0.02 and delta / canopy_prev < -0.5:
|
| 434 |
+
score = min(score, 0.15)
|
| 435 |
+
# Tripling canopy in days means the framing changed or it is a
|
| 436 |
+
# different plant.
|
| 437 |
+
elif canopy_prev > 0.02 and delta / canopy_prev > 2.0:
|
| 438 |
+
score = min(score, 0.3)
|
| 439 |
+
|
| 440 |
+
if trunk_mm is not None and trunk_prev_mm is not None:
|
| 441 |
+
out["delta_mm"] = round(trunk_mm - trunk_prev_mm, 1)
|
| 442 |
+
# A trunk cannot shrink. Allow 3mm for measurement error.
|
| 443 |
+
if trunk_mm < trunk_prev_mm - 3:
|
| 444 |
+
score = min(score, 0.1)
|
| 445 |
+
# No young tree gains 30mm of diameter in a check-in interval.
|
| 446 |
+
elif trunk_mm > trunk_prev_mm + 30:
|
| 447 |
+
score = min(score, 0.2)
|
| 448 |
+
|
| 449 |
+
out["score"] = score
|
| 450 |
+
return out
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
# ---------------------------------------------------------------------------
|
| 454 |
+
# Combination
|
| 455 |
+
# ---------------------------------------------------------------------------
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def assess(
|
| 459 |
+
gates: dict[str, GateResult],
|
| 460 |
+
scores: dict[str, dict],
|
| 461 |
+
weights: dict[str, float] | None = None,
|
| 462 |
+
review_flags: dict[str, GateResult] | None = None,
|
| 463 |
+
) -> Assessment:
|
| 464 |
+
"""`review_flags` are conditions that must not AUTO-APPROVE but are not
|
| 465 |
+
disqualifying — they demote 'verified' to 'review' and are recorded with a
|
| 466 |
+
reason. Separate from gates on purpose: a gate says "this is fraud", a review
|
| 467 |
+
flag says "a person should look at this", and collapsing the two would either
|
| 468 |
+
accuse honest planters or wave through the thing we wanted a human to see."""
|
| 469 |
+
w = dict(weights or DEFAULT_WEIGHTS)
|
| 470 |
+
review_flags = review_flags or {}
|
| 471 |
+
|
| 472 |
+
signals: dict = {
|
| 473 |
+
"gates": {k: v.detail for k, v in gates.items()},
|
| 474 |
+
"scores": scores,
|
| 475 |
+
"weights": w,
|
| 476 |
+
"model": {"name": MODEL_NAME, "version": MODEL_VERSION},
|
| 477 |
+
}
|
| 478 |
+
if review_flags:
|
| 479 |
+
signals["review_flags"] = {k: v.detail for k, v in review_flags.items()}
|
| 480 |
+
|
| 481 |
+
failed = [k for k, v in gates.items() if not v.passed]
|
| 482 |
+
if failed:
|
| 483 |
+
# Fail closed. A gate failure is not a low score, it is a
|
| 484 |
+
# disqualification, and reporting a partial confidence next to it would
|
| 485 |
+
# invite someone to override it.
|
| 486 |
+
signals["failed_gates"] = failed
|
| 487 |
+
return Assessment(0, "flagged", signals)
|
| 488 |
+
|
| 489 |
+
# Renormalise over the signals we actually have. A missing signal must lower
|
| 490 |
+
# confidence, never silently count as zero — "designed to degrade, not
|
| 491 |
+
# break" means an absent compass or a first visit routes to a human rather
|
| 492 |
+
# than being scored as fraud.
|
| 493 |
+
present = {k: v for k, v in w.items() if k in scores and "score" in scores[k]}
|
| 494 |
+
if not present:
|
| 495 |
+
return Assessment(0, "review", signals)
|
| 496 |
+
|
| 497 |
+
total_w = sum(present.values())
|
| 498 |
+
raw = sum(scores[k]["score"] * wt for k, wt in present.items()) / total_w
|
| 499 |
+
|
| 500 |
+
# Coverage penalty: if only half the weight was available, cap confidence
|
| 501 |
+
# accordingly instead of pretending a partial assessment is a full one.
|
| 502 |
+
coverage = total_w / sum(w.values())
|
| 503 |
+
confidence = int(round(100 * raw * (0.6 + 0.4 * coverage)))
|
| 504 |
+
|
| 505 |
+
signals["coverage"] = round(coverage, 3)
|
| 506 |
+
|
| 507 |
+
if confidence >= VERIFY_AT:
|
| 508 |
+
verdict = "verified"
|
| 509 |
+
elif confidence >= REVIEW_AT:
|
| 510 |
+
verdict = "review"
|
| 511 |
+
else:
|
| 512 |
+
verdict = "flagged"
|
| 513 |
+
|
| 514 |
+
# --- veto: no compensating away a failed critical signal ---------------
|
| 515 |
+
vetoes = [
|
| 516 |
+
name
|
| 517 |
+
for name, floor in CRITICAL_FLOORS.items()
|
| 518 |
+
if name in scores and scores[name].get("score", 1.0) < floor
|
| 519 |
+
]
|
| 520 |
+
if coverage < MIN_COVERAGE_TO_VERIFY:
|
| 521 |
+
vetoes.append("coverage")
|
| 522 |
+
vetoes.extend(name for name, r in review_flags.items() if not r.passed)
|
| 523 |
+
|
| 524 |
+
if vetoes and verdict == "verified":
|
| 525 |
+
verdict = "review"
|
| 526 |
+
signals["auto_approval_vetoed_by"] = vetoes
|
| 527 |
+
|
| 528 |
+
return Assessment(confidence, verdict, signals)
|
greenproof_ml/signals.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The individual detectors.
|
| 2 |
+
|
| 3 |
+
Each is deliberately small, pure and independently testable. Five of the six
|
| 4 |
+
fraud types in the attack set are caught here by ordinary deterministic code —
|
| 5 |
+
not by the neural network — and that is the honest reason the system works.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import functools
|
| 11 |
+
import math
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
from PIL import Image
|
| 17 |
+
|
| 18 |
+
# ---------------------------------------------------------------------------
|
| 19 |
+
# Perceptual hash — the duplicate detector
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
PHASH_SIZE = 32
|
| 23 |
+
PHASH_LOW = 8
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def phash(img: Image.Image) -> str:
|
| 27 |
+
"""64-bit DCT perceptual hash, as 16 hex characters.
|
| 28 |
+
|
| 29 |
+
Catches: resubmitting an identical photo, an old gallery photo of the same
|
| 30 |
+
tree, and a photo pulled off the internet that someone else also used.
|
| 31 |
+
|
| 32 |
+
DCT-based rather than average-hash because it survives the JPEG
|
| 33 |
+
re-compression the phone applies on resize, while still differing across
|
| 34 |
+
genuinely different photos. A re-saved copy of the same shot lands within a
|
| 35 |
+
couple of bits; two real photos of the same tree a week apart are typically
|
| 36 |
+
20+ bits apart.
|
| 37 |
+
"""
|
| 38 |
+
a = np.asarray(img.convert("L").resize((PHASH_SIZE, PHASH_SIZE), Image.BICUBIC), dtype=np.float32)
|
| 39 |
+
d = cv2.dct(a)[:PHASH_LOW, :PHASH_LOW]
|
| 40 |
+
|
| 41 |
+
# Exclude the DC term from the median: it carries overall brightness, which
|
| 42 |
+
# would otherwise drag the threshold around with the weather.
|
| 43 |
+
flat = d.flatten()[1:]
|
| 44 |
+
bits = (d.flatten() > np.median(flat)).astype(np.uint8)
|
| 45 |
+
|
| 46 |
+
out = 0
|
| 47 |
+
for bit in bits:
|
| 48 |
+
out = (out << 1) | int(bit)
|
| 49 |
+
return f"{out:016x}"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def hamming(a: str, b: str) -> int:
|
| 53 |
+
return bin(int(a, 16) ^ int(b, 16)).count("1")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# Below this many differing bits, treat two photos as the same image.
|
| 57 |
+
# Provisional — recalibrate from the T0/round-1 distribution of genuine
|
| 58 |
+
# same-tree pairs, which must sit comfortably ABOVE it.
|
| 59 |
+
DUPLICATE_MAX_HAMMING = 6
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# Liveness — is this a living plant at all?
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def excess_green(img: Image.Image) -> float:
|
| 68 |
+
"""Fraction of the frame that reads as living vegetation.
|
| 69 |
+
|
| 70 |
+
ExG = 2G - R - B on channels normalised per-pixel, which is the standard
|
| 71 |
+
agronomy vegetation index for ordinary RGB cameras. Per-pixel normalisation
|
| 72 |
+
is what makes it survive Ghanaian midday sun and deep shade in the same
|
| 73 |
+
frame — an absolute green threshold would not.
|
| 74 |
+
|
| 75 |
+
Catches: photographing a dead brown stick, a plank, or the ground.
|
| 76 |
+
It does NOT prove the plant is the right plant. That is a different signal.
|
| 77 |
+
"""
|
| 78 |
+
a = np.asarray(img.convert("RGB").resize((256, 256), Image.BILINEAR), dtype=np.float32)
|
| 79 |
+
total = a.sum(axis=2) + 1e-6
|
| 80 |
+
r, g, b = a[..., 0] / total, a[..., 1] / total, a[..., 2] / total
|
| 81 |
+
exg = 2 * g - r - b
|
| 82 |
+
# 0.05 is the conventional cut for "this pixel is vegetation".
|
| 83 |
+
return float((exg > 0.05).mean())
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def liveness_score(frac: float) -> float:
|
| 87 |
+
"""Map green fraction to 0..1.
|
| 88 |
+
|
| 89 |
+
A healthy young tree in a wide shot typically fills 0.25-0.6 of the frame
|
| 90 |
+
with vegetation; below ~0.05 there is essentially nothing living in view.
|
| 91 |
+
Ramped rather than stepped so a leaf-dropping tree degrades smoothly instead
|
| 92 |
+
of falling off a cliff — the point is to flag decline, not to fail it.
|
| 93 |
+
"""
|
| 94 |
+
return float(np.clip((frac - 0.05) / (0.30 - 0.05), 0.0, 1.0))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ---------------------------------------------------------------------------
|
| 98 |
+
# Plant plausibility - "is this even a plant?"
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
#
|
| 101 |
+
# WHY THIS IS NOT THE LIVENESS SIGNAL. excess_green measures how much of the
|
| 102 |
+
# frame is vegetation-COLOURED. Measured against 250 COCO photographs, a green
|
| 103 |
+
# painted wall and artificial turf both score 1.00, and the median ordinary
|
| 104 |
+
# outdoor photo scores 0.47 because ordinary outdoor photos contain grass. No
|
| 105 |
+
# threshold on green separates a tree from a chair; we tried, and raising the
|
| 106 |
+
# floor rejects real trees faster than it rejects furniture.
|
| 107 |
+
#
|
| 108 |
+
# So this asks a different question of the embedding we already compute: does
|
| 109 |
+
# this photo resemble anything in a reference set of known plants?
|
| 110 |
+
|
| 111 |
+
_REFERENCE_FILE = Path(__file__).resolve().parent / "plant_reference.npy"
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@functools.lru_cache(maxsize=1)
|
| 115 |
+
def plant_reference() -> "np.ndarray | None":
|
| 116 |
+
"""Unit-normalised embeddings of known plants, or None if unavailable.
|
| 117 |
+
|
| 118 |
+
None is a supported state, not an error: a missing reference file must
|
| 119 |
+
lower confidence and route to a human, never reject a real planter's tree.
|
| 120 |
+
|
| 121 |
+
THE REFERENCE SET IS THE LIMIT OF THIS CHECK. It is 74 photos of 15 plants
|
| 122 |
+
at one Ghanaian campus in one season, so it recognises "a plant
|
| 123 |
+
photographed the way our reference photos were". Swapping in PlantNet-300K
|
| 124 |
+
scored a perfect AUC and then flagged 97% of our own photos, because
|
| 125 |
+
PlantNet is square cropped specimens and ours are wide shots with sky and
|
| 126 |
+
buildings in frame - it had learned the photo STYLE, not the subject. The
|
| 127 |
+
set must therefore grow from our own verified check-ins, which is also why
|
| 128 |
+
the check gets better the longer the system runs.
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
v = np.load(_REFERENCE_FILE)
|
| 132 |
+
return v if v.ndim == 2 and v.shape[0] else None
|
| 133 |
+
except Exception: # noqa: BLE001 - degrade, don't break
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# Scene match — ORB + RANSAC, corroborating the embedding
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def orb_inliers(a: Image.Image, b: Image.Image, max_side: int = 640) -> int:
|
| 143 |
+
"""Count geometrically consistent keypoint matches between two photos.
|
| 144 |
+
|
| 145 |
+
Complements the DINOv2 embedding rather than duplicating it. The embedding
|
| 146 |
+
answers "does this look like the same kind of scene"; ORB+RANSAC answers
|
| 147 |
+
"are the same physical points present in a consistent geometric arrangement".
|
| 148 |
+
An attacker can find a visually similar tree far more easily than one that
|
| 149 |
+
matches point-for-point.
|
| 150 |
+
|
| 151 |
+
Returns 0 when there is no consistent geometry at all.
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
+
def prep(img: Image.Image) -> np.ndarray:
|
| 155 |
+
g = np.asarray(img.convert("L"))
|
| 156 |
+
h, w = g.shape
|
| 157 |
+
s = max_side / max(h, w)
|
| 158 |
+
if s < 1:
|
| 159 |
+
g = cv2.resize(g, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA)
|
| 160 |
+
return g
|
| 161 |
+
|
| 162 |
+
ga, gb = prep(a), prep(b)
|
| 163 |
+
orb = cv2.ORB_create(nfeatures=1500)
|
| 164 |
+
ka, da = orb.detectAndCompute(ga, None)
|
| 165 |
+
kb, db = orb.detectAndCompute(gb, None)
|
| 166 |
+
if da is None or db is None or len(ka) < 8 or len(kb) < 8:
|
| 167 |
+
return 0
|
| 168 |
+
|
| 169 |
+
matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
|
| 170 |
+
raw = matcher.knnMatch(da, db, k=2)
|
| 171 |
+
|
| 172 |
+
# Lowe's ratio test: keep a match only if it is clearly better than the
|
| 173 |
+
# runner-up. Foliage produces enormous numbers of near-identical descriptors,
|
| 174 |
+
# so without this the match list is almost entirely noise.
|
| 175 |
+
good = [m for m, n in (p for p in raw if len(p) == 2) if m.distance < 0.75 * n.distance]
|
| 176 |
+
if len(good) < 8:
|
| 177 |
+
return 0
|
| 178 |
+
|
| 179 |
+
src = np.float32([ka[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 180 |
+
dst = np.float32([kb[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 181 |
+
_, mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0)
|
| 182 |
+
return 0 if mask is None else int(mask.sum())
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
# ArUco — turning a photo into millimetres
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
|
| 189 |
+
DEFAULT_MARKER_MM = 80.0
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def find_aruco(img: Image.Image) -> dict | None:
|
| 193 |
+
"""Locate the reference marker and return millimetres-per-pixel.
|
| 194 |
+
|
| 195 |
+
The marker solves two problems at once, which is why it beats holding up a
|
| 196 |
+
coin or a ruler:
|
| 197 |
+
|
| 198 |
+
* SCALE — its real edge length is known, so we get mm per pixel
|
| 199 |
+
* TILT — four corners of a known square let us see that the card was
|
| 200 |
+
photographed at an angle. A plain rectangle cannot give this,
|
| 201 |
+
and an uncorrected 30 degree tilt is a ~15% error in width.
|
| 202 |
+
|
| 203 |
+
`tilt_ratio` near 1.0 means the card was square-on. Far from 1.0 means the
|
| 204 |
+
measurement should be distrusted rather than silently accepted.
|
| 205 |
+
"""
|
| 206 |
+
a = np.asarray(img.convert("L"))
|
| 207 |
+
d = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50)
|
| 208 |
+
corners, ids, _ = cv2.aruco.ArucoDetector(d, cv2.aruco.DetectorParameters()).detectMarkers(a)
|
| 209 |
+
if ids is None or len(corners) == 0:
|
| 210 |
+
return None
|
| 211 |
+
|
| 212 |
+
pts = corners[0].reshape(4, 2)
|
| 213 |
+
sides = [float(np.linalg.norm(pts[i] - pts[(i + 1) % 4])) for i in range(4)]
|
| 214 |
+
px = float(np.mean(sides))
|
| 215 |
+
if px < 10: # too small in frame to measure anything from
|
| 216 |
+
return None
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
"marker_id": int(ids.flatten()[0]),
|
| 220 |
+
"side_px": px,
|
| 221 |
+
"mm_per_px": DEFAULT_MARKER_MM / px,
|
| 222 |
+
"tilt_ratio": float(min(sides) / max(sides)),
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def trunk_width_mm(img: Image.Image, taps: dict | None) -> dict | None:
|
| 227 |
+
"""Convert the user's two trunk-edge taps into millimetres.
|
| 228 |
+
|
| 229 |
+
THIS IS THE ONLY PLACE MILLIMETRES ARE PRODUCED, and it runs server-side on
|
| 230 |
+
purpose. The client sends where a finger touched; it never sends a width.
|
| 231 |
+
Trunk width feeds the "a trunk cannot shrink" plausibility check, so a
|
| 232 |
+
client-supplied width would let a forged request defeat that check.
|
| 233 |
+
|
| 234 |
+
Requires the ArUco marker to be visible in the same frame — without a known
|
| 235 |
+
reference length a photo has no scale at all, and a thin trunk photographed
|
| 236 |
+
close up is pixel-for-pixel identical to a thick one photographed far away.
|
| 237 |
+
|
| 238 |
+
Returns None when the marker is absent, which is a normal outcome: the visit
|
| 239 |
+
still counts, the growth signal is simply unavailable and confidence drops
|
| 240 |
+
via the coverage term rather than the check failing.
|
| 241 |
+
"""
|
| 242 |
+
if not taps:
|
| 243 |
+
return None
|
| 244 |
+
|
| 245 |
+
marker = find_aruco(img)
|
| 246 |
+
if marker is None:
|
| 247 |
+
return {"available": False, "reason": "no marker detected"}
|
| 248 |
+
|
| 249 |
+
try:
|
| 250 |
+
lx, ly = float(taps["left"]["x"]), float(taps["left"]["y"])
|
| 251 |
+
rx, ry = float(taps["right"]["x"]), float(taps["right"]["y"])
|
| 252 |
+
except (KeyError, TypeError, ValueError):
|
| 253 |
+
return {"available": False, "reason": "malformed taps"}
|
| 254 |
+
|
| 255 |
+
w, h = img.size
|
| 256 |
+
# Euclidean, not just horizontal: people rarely tap two points at exactly
|
| 257 |
+
# the same height, and on a leaning trunk they should not.
|
| 258 |
+
dx, dy = (rx - lx) * w, (ry - ly) * h
|
| 259 |
+
px = math.hypot(dx, dy)
|
| 260 |
+
|
| 261 |
+
out = {
|
| 262 |
+
"available": True,
|
| 263 |
+
"trunk_px": round(px, 1),
|
| 264 |
+
"mm_per_px": round(marker["mm_per_px"], 5),
|
| 265 |
+
"marker_id": marker["marker_id"],
|
| 266 |
+
"tilt_ratio": round(marker["tilt_ratio"], 3),
|
| 267 |
+
"trunk_mm": round(px * marker["mm_per_px"], 1),
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
# A badly tilted card means the marker's apparent width is foreshortened,
|
| 271 |
+
# so mm-per-pixel is wrong and so is everything derived from it. Flag rather
|
| 272 |
+
# than silently returning a confident wrong number.
|
| 273 |
+
if marker["tilt_ratio"] < 0.80:
|
| 274 |
+
out["suspect"] = "marker photographed at a steep angle; hold it flat"
|
| 275 |
+
|
| 276 |
+
return out
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ---------------------------------------------------------------------------
|
| 280 |
+
# Canopy — the signal that actually moves in four weeks
|
| 281 |
+
# ---------------------------------------------------------------------------
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def canopy_fraction(img: Image.Image) -> float:
|
| 285 |
+
"""Share of the wide shot occupied by vegetation.
|
| 286 |
+
|
| 287 |
+
Unlike trunk diameter, this changes measurably in weeks: a stressed tree
|
| 288 |
+
drops leaves, and July-August is the rainy season so a healthy tree is
|
| 289 |
+
putting on leaf. This is the dying-tree detector, and it is why the pilot
|
| 290 |
+
deliberately includes struggling trees.
|
| 291 |
+
|
| 292 |
+
Only comparable BETWEEN VISITS when framing is reproduced — which is what
|
| 293 |
+
the compass heading and the ghost overlay exist to achieve.
|
| 294 |
+
"""
|
| 295 |
+
return excess_green(img)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# ---------------------------------------------------------------------------
|
| 299 |
+
# Travel plausibility
|
| 300 |
+
# ---------------------------------------------------------------------------
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
| 304 |
+
r = 6_371_000.0
|
| 305 |
+
p1, p2 = math.radians(lat1), math.radians(lat2)
|
| 306 |
+
dp = math.radians(lat2 - lat1)
|
| 307 |
+
dl = math.radians(lng2 - lng1)
|
| 308 |
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
| 309 |
+
return 2 * r * math.asin(math.sqrt(a))
|
greenproof_ml/store.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""All database and storage access, in one place.
|
| 2 |
+
|
| 3 |
+
Runs with the SERVICE ROLE key, which bypasses RLS and the column grants. That
|
| 4 |
+
is the whole security model in one sentence: the browser physically cannot write
|
| 5 |
+
`confidence`, `verdict` or `signals` because those columns are not granted to
|
| 6 |
+
`authenticated`, and this service is the only thing holding a key that can.
|
| 7 |
+
|
| 8 |
+
The service key must never reach the frontend. It lives only in the Hugging Face
|
| 9 |
+
Space's secrets.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import io
|
| 15 |
+
import math
|
| 16 |
+
import os
|
| 17 |
+
from datetime import datetime, timezone
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
from PIL import Image
|
| 21 |
+
from supabase import Client, create_client
|
| 22 |
+
|
| 23 |
+
BUCKET = "tree-photos"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _client() -> Client:
|
| 27 |
+
url = os.environ.get("SUPABASE_URL")
|
| 28 |
+
key = os.environ.get("SUPABASE_SERVICE_KEY")
|
| 29 |
+
if not url or not key:
|
| 30 |
+
raise RuntimeError(
|
| 31 |
+
"SUPABASE_URL and SUPABASE_SERVICE_KEY must be set. "
|
| 32 |
+
"On Hugging Face Spaces these go in Settings -> Variables and secrets."
|
| 33 |
+
)
|
| 34 |
+
return create_client(url, key)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_db: Client | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def db() -> Client:
|
| 41 |
+
global _db
|
| 42 |
+
if _db is None:
|
| 43 |
+
_db = _client()
|
| 44 |
+
return _db
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def parse_ts(value: str) -> datetime:
|
| 48 |
+
"""Postgres timestamptz -> aware datetime.
|
| 49 |
+
|
| 50 |
+
Postgres emits '+00:00' or a 6-digit fractional second; Python's fromisoformat
|
| 51 |
+
is fussy about 'Z' on older versions. Normalising here stops a timezone bug
|
| 52 |
+
from silently turning the travel-speed gate into nonsense.
|
| 53 |
+
"""
|
| 54 |
+
v = value.replace("Z", "+00:00")
|
| 55 |
+
dt = datetime.fromisoformat(v)
|
| 56 |
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def vector_literal(v: np.ndarray) -> str:
|
| 60 |
+
"""pgvector wants '[0.1,0.2,...]' as text, not a JSON array."""
|
| 61 |
+
return "[" + ",".join(f"{x:.6f}" for x in v.tolist()) + "]"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Reads
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_checkin(checkin_id: str) -> dict | None:
|
| 70 |
+
r = db().table("checkins").select("*").eq("id", checkin_id).limit(1).execute()
|
| 71 |
+
return r.data[0] if r.data else None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_tree(tree_id: str) -> dict | None:
|
| 75 |
+
r = db().table("trees").select("*").eq("id", tree_id).limit(1).execute()
|
| 76 |
+
return r.data[0] if r.data else None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def list_pending(limit: int = 50) -> list[dict]:
|
| 80 |
+
r = (
|
| 81 |
+
db()
|
| 82 |
+
.table("checkins")
|
| 83 |
+
.select("id")
|
| 84 |
+
.eq("verdict", "pending")
|
| 85 |
+
.order("server_received_at", desc=False)
|
| 86 |
+
.limit(limit)
|
| 87 |
+
.execute()
|
| 88 |
+
)
|
| 89 |
+
return r.data or []
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def other_phashes(exclude_checkin_id: str) -> list[tuple[str, str]]:
|
| 93 |
+
"""Every other check-in's pHash, for the duplicate gate.
|
| 94 |
+
|
| 95 |
+
Deliberately global, not scoped to this tree: submitting tree 7's photo as
|
| 96 |
+
tree 12's check-in is one of the attacks, and scoping the search to tree 12
|
| 97 |
+
would miss it entirely.
|
| 98 |
+
"""
|
| 99 |
+
r = (
|
| 100 |
+
db()
|
| 101 |
+
.table("checkins")
|
| 102 |
+
.select("id, phash")
|
| 103 |
+
.not_.is_("phash", "null")
|
| 104 |
+
.neq("id", exclude_checkin_id)
|
| 105 |
+
.execute()
|
| 106 |
+
)
|
| 107 |
+
return [(row["id"], row["phash"]) for row in (r.data or []) if row.get("phash")]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def trees_near_registered_before(
|
| 111 |
+
tree_id: str,
|
| 112 |
+
lat: float,
|
| 113 |
+
lng: float,
|
| 114 |
+
before_iso: str,
|
| 115 |
+
radius_m: float,
|
| 116 |
+
) -> list[tuple[str, float, float]]:
|
| 117 |
+
"""Trees within `radius_m` that were registered BEFORE this one.
|
| 118 |
+
|
| 119 |
+
Across all owners: one physical tree registered by two different people is
|
| 120 |
+
the double-payment case, and scoping this to the owner would miss exactly
|
| 121 |
+
that.
|
| 122 |
+
|
| 123 |
+
A bounding box does the elimination in Postgres and the true distance is
|
| 124 |
+
measured here. PostGIS is available and `trees.location` is indexed, but
|
| 125 |
+
reaching it needs a SQL function; the box uses the plain lat/lng columns and
|
| 126 |
+
is correct because the caller re-measures. At pilot scale the difference is
|
| 127 |
+
unmeasurable — the box is there so this does not become a full table scan
|
| 128 |
+
when the grove is 40,000 trees rather than 40.
|
| 129 |
+
"""
|
| 130 |
+
d_lat = radius_m / 111_320.0
|
| 131 |
+
d_lng = radius_m / max(111_320.0 * math.cos(math.radians(lat)), 1e-6)
|
| 132 |
+
|
| 133 |
+
r = (
|
| 134 |
+
db()
|
| 135 |
+
.table("trees")
|
| 136 |
+
.select("id, lat, lng")
|
| 137 |
+
.gte("lat", lat - d_lat)
|
| 138 |
+
.lte("lat", lat + d_lat)
|
| 139 |
+
.gte("lng", lng - d_lng)
|
| 140 |
+
.lte("lng", lng + d_lng)
|
| 141 |
+
.lt("created_at", before_iso)
|
| 142 |
+
.neq("id", tree_id)
|
| 143 |
+
.execute()
|
| 144 |
+
)
|
| 145 |
+
return [(row["id"], row["lat"], row["lng"]) for row in (r.data or [])]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def previous_checkins(tree_id: str, before_iso: str) -> list[dict]:
|
| 149 |
+
"""Earlier visits to this tree, newest first, with embeddings."""
|
| 150 |
+
r = (
|
| 151 |
+
db()
|
| 152 |
+
.table("checkins")
|
| 153 |
+
.select(
|
| 154 |
+
"id, wide_photo, close_photo, captured_at, wide_embedding, "
|
| 155 |
+
"close_embedding, signals, verdict"
|
| 156 |
+
)
|
| 157 |
+
.eq("tree_id", tree_id)
|
| 158 |
+
.lt("captured_at", before_iso)
|
| 159 |
+
.order("captured_at", desc=True)
|
| 160 |
+
.execute()
|
| 161 |
+
)
|
| 162 |
+
return r.data or []
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def previous_by_submitter(
|
| 166 |
+
submitter_id: str, before_iso: str, exclude_checkin_id: str
|
| 167 |
+
) -> dict | None:
|
| 168 |
+
"""The submitter's most recent check-in anywhere — for the travel gate."""
|
| 169 |
+
r = (
|
| 170 |
+
db()
|
| 171 |
+
.table("checkins")
|
| 172 |
+
.select("id, lat, lng, captured_at")
|
| 173 |
+
.eq("submitted_by", submitter_id)
|
| 174 |
+
.lt("captured_at", before_iso)
|
| 175 |
+
.neq("id", exclude_checkin_id)
|
| 176 |
+
.order("captured_at", desc=True)
|
| 177 |
+
.limit(1)
|
| 178 |
+
.execute()
|
| 179 |
+
)
|
| 180 |
+
return r.data[0] if r.data else None
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def download_image(path: str) -> Image.Image:
|
| 184 |
+
raw = db().storage.from_(BUCKET).download(path)
|
| 185 |
+
return Image.open(io.BytesIO(raw))
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def parse_embedding(value) -> np.ndarray | None:
|
| 189 |
+
"""pgvector comes back as either a string literal or a list, depending on
|
| 190 |
+
the PostgREST version. Handle both rather than discovering it at 2am."""
|
| 191 |
+
if value is None:
|
| 192 |
+
return None
|
| 193 |
+
if isinstance(value, str):
|
| 194 |
+
value = value.strip().strip("[]")
|
| 195 |
+
if not value:
|
| 196 |
+
return None
|
| 197 |
+
return np.fromstring(value, sep=",", dtype=np.float32)
|
| 198 |
+
return np.asarray(value, dtype=np.float32)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ---------------------------------------------------------------------------
|
| 202 |
+
# Writes — only this process may perform them
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def write_result(
|
| 207 |
+
checkin_id: str,
|
| 208 |
+
*,
|
| 209 |
+
phash: str,
|
| 210 |
+
wide_embedding: np.ndarray,
|
| 211 |
+
close_embedding: np.ndarray,
|
| 212 |
+
confidence: int,
|
| 213 |
+
verdict: str,
|
| 214 |
+
signals: dict,
|
| 215 |
+
audit_sample: bool = False,
|
| 216 |
+
) -> None:
|
| 217 |
+
db().table("checkins").update(
|
| 218 |
+
{
|
| 219 |
+
"phash": phash,
|
| 220 |
+
"wide_embedding": vector_literal(wide_embedding),
|
| 221 |
+
"close_embedding": vector_literal(close_embedding),
|
| 222 |
+
"confidence": confidence,
|
| 223 |
+
"verdict": verdict,
|
| 224 |
+
"signals": signals,
|
| 225 |
+
"audit_sample": audit_sample,
|
| 226 |
+
}
|
| 227 |
+
).eq("id", checkin_id).execute()
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def write_advice(checkin_id: str, species_guess: dict, advice: dict) -> None:
|
| 231 |
+
"""Store the advisory assessment.
|
| 232 |
+
|
| 233 |
+
Writes ONLY the two advisory columns. It does not touch `confidence`,
|
| 234 |
+
`verdict` or `signals`, and that separation is the whole point: advice is
|
| 235 |
+
generated by a component whose error rate we have not measured, so it must
|
| 236 |
+
not be able to move a number we publish.
|
| 237 |
+
|
| 238 |
+
Note this is a separate write from write_result rather than a parameter on
|
| 239 |
+
it. Scoring is replayable and re-run whenever a threshold changes; advice is
|
| 240 |
+
a paid external call that should survive a re-score untouched. Folding the
|
| 241 |
+
two together would either destroy advice on every recalibration or make
|
| 242 |
+
recalibration cost money.
|
| 243 |
+
"""
|
| 244 |
+
db().table("checkins").update(
|
| 245 |
+
{"species_guess": species_guess, "advice": advice}
|
| 246 |
+
).eq("id", checkin_id).execute()
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def list_checkins_without_advice(limit: int = 100) -> list[dict]:
|
| 250 |
+
"""Check-ins that have never been assessed, oldest first."""
|
| 251 |
+
r = (
|
| 252 |
+
db()
|
| 253 |
+
.table("checkins")
|
| 254 |
+
.select("id")
|
| 255 |
+
.is_("advice", "null")
|
| 256 |
+
.order("server_received_at", desc=False)
|
| 257 |
+
.limit(limit)
|
| 258 |
+
.execute()
|
| 259 |
+
)
|
| 260 |
+
return r.data or []
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def update_tree_status(tree_id: str, confidence: int, verdict: str) -> None:
|
| 264 |
+
"""Roll the latest verdict up onto the tree.
|
| 265 |
+
|
| 266 |
+
'alive' only on a verified check-in. A tree never becomes 'dead'
|
| 267 |
+
automatically — that is a human decision, because being wrong about it stops
|
| 268 |
+
someone's payment.
|
| 269 |
+
"""
|
| 270 |
+
status = {
|
| 271 |
+
"verified": "alive",
|
| 272 |
+
"review": "pending",
|
| 273 |
+
"flagged": "flagged",
|
| 274 |
+
}.get(verdict, "pending")
|
| 275 |
+
|
| 276 |
+
db().table("trees").update(
|
| 277 |
+
{"status": status, "current_confidence": confidence}
|
| 278 |
+
).eq("id", tree_id).execute()
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pinned loosely — Hugging Face Spaces rebuilds on push and a surprise major
|
| 2 |
+
# version on pitch week is not a risk worth taking for a few KB of convenience.
|
| 3 |
+
|
| 4 |
+
fastapi>=0.115,<1
|
| 5 |
+
uvicorn[standard]>=0.32,<1
|
| 6 |
+
pydantic>=2.9,<3
|
| 7 |
+
|
| 8 |
+
# onnxruntime, NOT torch. Torch pulls ~2 GB and would make cold starts on the
|
| 9 |
+
# free Spaces tier unusable. Torch stays a dev-only dependency for the spike.
|
| 10 |
+
onnxruntime>=1.19,<2
|
| 11 |
+
huggingface_hub>=0.26,<1
|
| 12 |
+
|
| 13 |
+
numpy>=1.26,<3
|
| 14 |
+
pillow>=10.4,<12
|
| 15 |
+
|
| 16 |
+
# contrib, not plain opencv — cv2.aruco lives in contrib and the ArUco marker is
|
| 17 |
+
# how a photo becomes a measurement in millimetres.
|
| 18 |
+
opencv-contrib-python-headless>=4.10,<5
|
| 19 |
+
|
| 20 |
+
supabase>=2.9,<3
|
| 21 |
+
httpx>=0.27,<1
|
| 22 |
+
|
| 23 |
+
# Advisory layer only: species identification and care advice for the planter.
|
| 24 |
+
# NOT on the scoring path - pipeline.py never imports the advisor, so a slow or
|
| 25 |
+
# failed API call cannot affect a verdict.
|
| 26 |
+
anthropic>=0.40
|