Spaces:
Running
Running
| """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" | |
| 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)) | |