Spaces:
Running
Running
File size: 12,623 Bytes
f6f7b53 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | """The individual detectors.
Each is deliberately small, pure and independently testable. Five of the six
fraud types in the attack set are caught here by ordinary deterministic code β
not by the neural network β and that is the honest reason the system works.
"""
from __future__ import annotations
import functools
import math
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
# ---------------------------------------------------------------------------
# Perceptual hash β the duplicate detector
# ---------------------------------------------------------------------------
PHASH_SIZE = 32
PHASH_LOW = 8
def phash(img: Image.Image) -> str:
"""64-bit DCT perceptual hash, as 16 hex characters.
Catches: resubmitting an identical photo, an old gallery photo of the same
tree, and a photo pulled off the internet that someone else also used.
DCT-based rather than average-hash because it survives the JPEG
re-compression the phone applies on resize, while still differing across
genuinely different photos. A re-saved copy of the same shot lands within a
couple of bits; two real photos of the same tree a week apart are typically
20+ bits apart.
"""
a = np.asarray(img.convert("L").resize((PHASH_SIZE, PHASH_SIZE), Image.BICUBIC), dtype=np.float32)
d = cv2.dct(a)[:PHASH_LOW, :PHASH_LOW]
# Exclude the DC term from the median: it carries overall brightness, which
# would otherwise drag the threshold around with the weather.
flat = d.flatten()[1:]
bits = (d.flatten() > np.median(flat)).astype(np.uint8)
out = 0
for bit in bits:
out = (out << 1) | int(bit)
return f"{out:016x}"
def hamming(a: str, b: str) -> int:
return bin(int(a, 16) ^ int(b, 16)).count("1")
# Below this many differing bits, treat two photos as the same image.
# Provisional β recalibrate from the T0/round-1 distribution of genuine
# same-tree pairs, which must sit comfortably ABOVE it.
DUPLICATE_MAX_HAMMING = 6
# ---------------------------------------------------------------------------
# Liveness β is this a living plant at all?
# ---------------------------------------------------------------------------
def excess_green(img: Image.Image) -> float:
"""Fraction of the frame that reads as living vegetation.
ExG = 2G - R - B on channels normalised per-pixel, which is the standard
agronomy vegetation index for ordinary RGB cameras. Per-pixel normalisation
is what makes it survive Ghanaian midday sun and deep shade in the same
frame β an absolute green threshold would not.
Catches: photographing a dead brown stick, a plank, or the ground.
It does NOT prove the plant is the right plant. That is a different signal.
"""
a = np.asarray(img.convert("RGB").resize((256, 256), Image.BILINEAR), dtype=np.float32)
total = a.sum(axis=2) + 1e-6
r, g, b = a[..., 0] / total, a[..., 1] / total, a[..., 2] / total
exg = 2 * g - r - b
# 0.05 is the conventional cut for "this pixel is vegetation".
return float((exg > 0.05).mean())
def liveness_score(frac: float) -> float:
"""Map green fraction to 0..1.
A healthy young tree in a wide shot typically fills 0.25-0.6 of the frame
with vegetation; below ~0.05 there is essentially nothing living in view.
Ramped rather than stepped so a leaf-dropping tree degrades smoothly instead
of falling off a cliff β the point is to flag decline, not to fail it.
"""
return float(np.clip((frac - 0.05) / (0.30 - 0.05), 0.0, 1.0))
# ---------------------------------------------------------------------------
# Plant plausibility - "is this even a plant?"
# ---------------------------------------------------------------------------
#
# WHY THIS IS NOT THE LIVENESS SIGNAL. excess_green measures how much of the
# frame is vegetation-COLOURED. Measured against 250 COCO photographs, a green
# painted wall and artificial turf both score 1.00, and the median ordinary
# outdoor photo scores 0.47 because ordinary outdoor photos contain grass. No
# threshold on green separates a tree from a chair; we tried, and raising the
# floor rejects real trees faster than it rejects furniture.
#
# So this asks a different question of the embedding we already compute: does
# this photo resemble anything in a reference set of known plants?
_REFERENCE_FILE = Path(__file__).resolve().parent / "plant_reference.npy"
@functools.lru_cache(maxsize=1)
def plant_reference() -> "np.ndarray | None":
"""Unit-normalised embeddings of known plants, or None if unavailable.
None is a supported state, not an error: a missing reference file must
lower confidence and route to a human, never reject a real planter's tree.
THE REFERENCE SET IS THE LIMIT OF THIS CHECK. It is 74 photos of 15 plants
at one Ghanaian campus in one season, so it recognises "a plant
photographed the way our reference photos were". Swapping in PlantNet-300K
scored a perfect AUC and then flagged 97% of our own photos, because
PlantNet is square cropped specimens and ours are wide shots with sky and
buildings in frame - it had learned the photo STYLE, not the subject. The
set must therefore grow from our own verified check-ins, which is also why
the check gets better the longer the system runs.
"""
try:
v = np.load(_REFERENCE_FILE)
return v if v.ndim == 2 and v.shape[0] else None
except Exception: # noqa: BLE001 - degrade, don't break
return None
# ---------------------------------------------------------------------------
# Scene match β ORB + RANSAC, corroborating the embedding
# ---------------------------------------------------------------------------
def orb_inliers(a: Image.Image, b: Image.Image, max_side: int = 640) -> int:
"""Count geometrically consistent keypoint matches between two photos.
Complements the DINOv2 embedding rather than duplicating it. The embedding
answers "does this look like the same kind of scene"; ORB+RANSAC answers
"are the same physical points present in a consistent geometric arrangement".
An attacker can find a visually similar tree far more easily than one that
matches point-for-point.
Returns 0 when there is no consistent geometry at all.
"""
def prep(img: Image.Image) -> np.ndarray:
g = np.asarray(img.convert("L"))
h, w = g.shape
s = max_side / max(h, w)
if s < 1:
g = cv2.resize(g, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA)
return g
ga, gb = prep(a), prep(b)
orb = cv2.ORB_create(nfeatures=1500)
ka, da = orb.detectAndCompute(ga, None)
kb, db = orb.detectAndCompute(gb, None)
if da is None or db is None or len(ka) < 8 or len(kb) < 8:
return 0
matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
raw = matcher.knnMatch(da, db, k=2)
# Lowe's ratio test: keep a match only if it is clearly better than the
# runner-up. Foliage produces enormous numbers of near-identical descriptors,
# so without this the match list is almost entirely noise.
good = [m for m, n in (p for p in raw if len(p) == 2) if m.distance < 0.75 * n.distance]
if len(good) < 8:
return 0
src = np.float32([ka[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
dst = np.float32([kb[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
_, mask = cv2.findHomography(src, dst, cv2.RANSAC, 5.0)
return 0 if mask is None else int(mask.sum())
# ---------------------------------------------------------------------------
# ArUco β turning a photo into millimetres
# ---------------------------------------------------------------------------
DEFAULT_MARKER_MM = 80.0
def find_aruco(img: Image.Image) -> dict | None:
"""Locate the reference marker and return millimetres-per-pixel.
The marker solves two problems at once, which is why it beats holding up a
coin or a ruler:
* SCALE β its real edge length is known, so we get mm per pixel
* TILT β four corners of a known square let us see that the card was
photographed at an angle. A plain rectangle cannot give this,
and an uncorrected 30 degree tilt is a ~15% error in width.
`tilt_ratio` near 1.0 means the card was square-on. Far from 1.0 means the
measurement should be distrusted rather than silently accepted.
"""
a = np.asarray(img.convert("L"))
d = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50)
corners, ids, _ = cv2.aruco.ArucoDetector(d, cv2.aruco.DetectorParameters()).detectMarkers(a)
if ids is None or len(corners) == 0:
return None
pts = corners[0].reshape(4, 2)
sides = [float(np.linalg.norm(pts[i] - pts[(i + 1) % 4])) for i in range(4)]
px = float(np.mean(sides))
if px < 10: # too small in frame to measure anything from
return None
return {
"marker_id": int(ids.flatten()[0]),
"side_px": px,
"mm_per_px": DEFAULT_MARKER_MM / px,
"tilt_ratio": float(min(sides) / max(sides)),
}
def trunk_width_mm(img: Image.Image, taps: dict | None) -> dict | None:
"""Convert the user's two trunk-edge taps into millimetres.
THIS IS THE ONLY PLACE MILLIMETRES ARE PRODUCED, and it runs server-side on
purpose. The client sends where a finger touched; it never sends a width.
Trunk width feeds the "a trunk cannot shrink" plausibility check, so a
client-supplied width would let a forged request defeat that check.
Requires the ArUco marker to be visible in the same frame β without a known
reference length a photo has no scale at all, and a thin trunk photographed
close up is pixel-for-pixel identical to a thick one photographed far away.
Returns None when the marker is absent, which is a normal outcome: the visit
still counts, the growth signal is simply unavailable and confidence drops
via the coverage term rather than the check failing.
"""
if not taps:
return None
marker = find_aruco(img)
if marker is None:
return {"available": False, "reason": "no marker detected"}
try:
lx, ly = float(taps["left"]["x"]), float(taps["left"]["y"])
rx, ry = float(taps["right"]["x"]), float(taps["right"]["y"])
except (KeyError, TypeError, ValueError):
return {"available": False, "reason": "malformed taps"}
w, h = img.size
# Euclidean, not just horizontal: people rarely tap two points at exactly
# the same height, and on a leaning trunk they should not.
dx, dy = (rx - lx) * w, (ry - ly) * h
px = math.hypot(dx, dy)
out = {
"available": True,
"trunk_px": round(px, 1),
"mm_per_px": round(marker["mm_per_px"], 5),
"marker_id": marker["marker_id"],
"tilt_ratio": round(marker["tilt_ratio"], 3),
"trunk_mm": round(px * marker["mm_per_px"], 1),
}
# A badly tilted card means the marker's apparent width is foreshortened,
# so mm-per-pixel is wrong and so is everything derived from it. Flag rather
# than silently returning a confident wrong number.
if marker["tilt_ratio"] < 0.80:
out["suspect"] = "marker photographed at a steep angle; hold it flat"
return out
# ---------------------------------------------------------------------------
# Canopy β the signal that actually moves in four weeks
# ---------------------------------------------------------------------------
def canopy_fraction(img: Image.Image) -> float:
"""Share of the wide shot occupied by vegetation.
Unlike trunk diameter, this changes measurably in weeks: a stressed tree
drops leaves, and July-August is the rainy season so a healthy tree is
putting on leaf. This is the dying-tree detector, and it is why the pilot
deliberately includes struggling trees.
Only comparable BETWEEN VISITS when framing is reproduced β which is what
the compass heading and the ghost overlay exist to achieve.
"""
return excess_green(img)
# ---------------------------------------------------------------------------
# Travel plausibility
# ---------------------------------------------------------------------------
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
r = 6_371_000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
|