Safety fix: never return an edible verdict from a single photo
Browse filesThe live build has a 0.85-confidence shortcut that can return an edible verdict from one photo. In testing, two photos of toxic mushrooms cleared it as SAFE. This PR removes the shortcut, so an edible verdict always needs a second photo, and brings the Space up to date with the current pipeline.
Merging is all that's needed. From HomesteaderLabs (alex@homesteaderlabs.com).
- .github/workflows/observability.yml +56 -0
- .pytest_cache/.gitignore +2 -0
- .pytest_cache/CACHEDIR.TAG +4 -0
- .pytest_cache/README.md +8 -0
- .pytest_cache/v/cache/nodeids +13 -0
- STATUS.md +79 -0
- app.py +29 -8
- pipeline/convergence.py +4 -1
- pipeline/infer.py +57 -0
- pipeline/session.py +64 -0
- tests/test_two_photo.py +108 -0
.github/workflows/observability.yml
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: observability
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
paths:
|
| 6 |
+
- "observability/**"
|
| 7 |
+
- "pipeline/**"
|
| 8 |
+
- ".github/workflows/observability.yml"
|
| 9 |
+
pull_request:
|
| 10 |
+
paths:
|
| 11 |
+
- "observability/**"
|
| 12 |
+
- "pipeline/**"
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
test:
|
| 16 |
+
runs-on: ubuntu-latest
|
| 17 |
+
services:
|
| 18 |
+
postgres:
|
| 19 |
+
image: postgres:16
|
| 20 |
+
env:
|
| 21 |
+
POSTGRES_PASSWORD: forager
|
| 22 |
+
POSTGRES_DB: forager_fs_obs
|
| 23 |
+
ports:
|
| 24 |
+
- 5434:5432
|
| 25 |
+
options: >-
|
| 26 |
+
--health-cmd "pg_isready -U postgres"
|
| 27 |
+
--health-interval 2s
|
| 28 |
+
--health-timeout 5s
|
| 29 |
+
--health-retries 15
|
| 30 |
+
env:
|
| 31 |
+
FORAGER_OBS_DSN: postgresql://postgres:forager@localhost:5434/forager_fs_obs
|
| 32 |
+
steps:
|
| 33 |
+
# Two checkouts side by side, because observability/requirements.txt
|
| 34 |
+
# installs the shared package as `-e ../forager-obs`. Actions cannot check
|
| 35 |
+
# out above the workspace, so this repo goes in a subdirectory too and the
|
| 36 |
+
# later steps set working-directory.
|
| 37 |
+
- uses: actions/checkout@v4
|
| 38 |
+
with:
|
| 39 |
+
path: forager-field-station
|
| 40 |
+
- uses: actions/checkout@v4
|
| 41 |
+
with:
|
| 42 |
+
repository: thefullnacho/forager-obs
|
| 43 |
+
path: forager-obs
|
| 44 |
+
- uses: actions/setup-python@v5
|
| 45 |
+
with:
|
| 46 |
+
python-version: "3.13"
|
| 47 |
+
- name: Install deps (incl. editable forager-obs)
|
| 48 |
+
working-directory: forager-field-station
|
| 49 |
+
run: pip install -r observability/requirements.txt
|
| 50 |
+
- name: Shared safety rules (no DB, no models)
|
| 51 |
+
working-directory: forager-obs
|
| 52 |
+
run: python -m pytest -q
|
| 53 |
+
- name: Two-photo safety tests + observability schema/views
|
| 54 |
+
working-directory: forager-field-station
|
| 55 |
+
run: |
|
| 56 |
+
python -m pytest observability/tests/ tests/test_two_photo.py -q
|
.pytest_cache/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Created by pytest automatically.
|
| 2 |
+
*
|
.pytest_cache/CACHEDIR.TAG
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 2 |
+
# This file is a cache directory tag created by pytest.
|
| 3 |
+
# For information about cache directory tags, see:
|
| 4 |
+
# https://bford.info/cachedir/spec.html
|
.pytest_cache/README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# pytest cache directory #
|
| 2 |
+
|
| 3 |
+
This directory contains data from the pytest's cache plugin,
|
| 4 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 5 |
+
|
| 6 |
+
**Do not** commit this to version control.
|
| 7 |
+
|
| 8 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
.pytest_cache/v/cache/nodeids
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
"observability/tests/test_observability.py::test_abstain_is_never_toxic_as_edible",
|
| 3 |
+
"observability/tests/test_observability.py::test_positive_recall_view",
|
| 4 |
+
"observability/tests/test_observability.py::test_safety_view_reports_zero_for_safe_handling",
|
| 5 |
+
"observability/tests/test_observability.py::test_writer_derives_toxic_as_edible",
|
| 6 |
+
"tests/test_two_photo.py::test_confident_deadly_mushroom_is_not_shown_safe",
|
| 7 |
+
"tests/test_two_photo.py::test_deadly_can_surface_on_single_photo",
|
| 8 |
+
"tests/test_two_photo.py::test_single_photo_edible_only_when_confident",
|
| 9 |
+
"tests/test_two_photo.py::test_single_photo_never_commits_edible",
|
| 10 |
+
"tests/test_two_photo.py::test_single_photo_never_surfaces_edible",
|
| 11 |
+
"tests/test_two_photo.py::test_two_agreeing_photos_can_commit_safe",
|
| 12 |
+
"tests/test_two_photo.py::test_two_photos_no_deadly_shown_edible"
|
| 13 |
+
]
|
STATUS.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# STATUS
|
| 2 |
+
|
| 3 |
+
Running log of where the Field Station actually is. Newest entry at the top. Append, do not
|
| 4 |
+
rewrite.
|
| 5 |
+
|
| 6 |
+
Cross-repo facts live in the Forager wiki at `~/Documents/Forager/forager-wiki/`.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 2026-08-15 β shared observability package; the dev harness stops shipping to the Space
|
| 11 |
+
|
| 12 |
+
**The dev harness was being uploaded to the public Space.** `deploy.py`'s IGNORE list excluded
|
| 13 |
+
`scripts/*` but not `observability/*`, so the whole Postgres harness went up with every deploy.
|
| 14 |
+
Now excluded. Verified the Space runtime never imported it: `app.py`, `pipeline/` and `game/`
|
| 15 |
+
are clean.
|
| 16 |
+
|
| 17 |
+
**`forager-obs` adopted.** The `toxic_as_edible` rule was implemented here inline in
|
| 18 |
+
`SessionWriter` and separately in forager_ml as a property on `InferenceOutcome`, with the
|
| 19 |
+
flagship 0.0 claim resting on both. It now comes from
|
| 20 |
+
`github.com/thefullnacho/forager-obs` (public, `main`), an editable sibling install, along with
|
| 21 |
+
connect/migrate, the batch-writer contract (`SessionWriter` is now a `BaseWriter`) and the
|
| 22 |
+
val-set walk including `k_groups`.
|
| 23 |
+
|
| 24 |
+
**The session-grained schema stays here.** forager_ml's is image-grained. Those answer
|
| 25 |
+
different questions and are deliberately not merged β recorded in the wiki so a later pass
|
| 26 |
+
does not "fix" it.
|
| 27 |
+
|
| 28 |
+
**Compose collision fixed.** This repo and forager_ml both bound host port 5433 *and* derived
|
| 29 |
+
the same Compose project name from their parent directory (`observability` in both), so
|
| 30 |
+
bringing up the second silently recreated the first's container. Now 5434 /
|
| 31 |
+
`forager-fs-obs`, and both run side by side for the first time.
|
| 32 |
+
|
| 33 |
+
**Verified:** 4 observability tests + 4 two-photo tests green against a live Postgres on 5434,
|
| 34 |
+
and the Space's pipeline imports unchanged.
|
| 35 |
+
|
| 36 |
+
### In flight
|
| 37 |
+
|
| 38 |
+
- **`two-photo-safety` branch is 1 commit ahead and this repo has no git remote.** It is a
|
| 39 |
+
local repo that deploys to Hugging Face via `deploy.py`.
|
| 40 |
+
- **The Space has not been redeployed.** The `deploy.py` exclusion takes effect on the next
|
| 41 |
+
`python deploy.py <user>/forager-field-station`, so `observability/` is still present in the
|
| 42 |
+
currently published Space.
|
| 43 |
+
|
| 44 |
+
### Next concrete action
|
| 45 |
+
|
| 46 |
+
Decide whether to redeploy the Space now (which removes the dev harness from the public repo)
|
| 47 |
+
or fold it into the next content deploy.
|
| 48 |
+
|
| 49 |
+
## 2026-10-25 β merge + GitHub remote
|
| 50 |
+
|
| 51 |
+
**Changed:** `two-photo-safety` fast-forwarded into `master` (two-photo safety protocol,
|
| 52 |
+
observability harness, confidence-banded single-photo gate, shared `forager-obs`). Repo now has
|
| 53 |
+
a private GitHub remote, `thefullnacho/forager-field-station`, default branch `master`; both
|
| 54 |
+
branches pushed. `assets/` (local backyard photos, video) is gitignored.
|
| 55 |
+
|
| 56 |
+
### In flight
|
| 57 |
+
|
| 58 |
+
- **The Space has still not been redeployed.** `observability/` is still in the published
|
| 59 |
+
Space until the next `deploy.py` run. [non-production] Approve the redeploy (it publishes).
|
| 60 |
+
- The `forager_ml` convergence drift check stays local-only. Its reason (no remote here) is
|
| 61 |
+
gone, so it could move into CI now.
|
| 62 |
+
|
| 63 |
+
### Next concrete action
|
| 64 |
+
|
| 65 |
+
Space redeploy deferred (2026-10-25): fold it into the next content deploy, which will also
|
| 66 |
+
remove `observability/` from the public Space.
|
| 67 |
+
|
| 68 |
+
## 2026-10-27 β safety fix deploy blocked
|
| 69 |
+
|
| 70 |
+
The fix that removes the 0.85 single-photo edible shortcut (`209f6b6`) is on master but **not
|
| 71 |
+
live**. The live Space is `build-small-hackathon/Forager-Field-Notes`, not the
|
| 72 |
+
`HomesteaderLabs/forager-field-station` named in the docs (that one doesn't exist). The
|
| 73 |
+
HomesteaderLabs token has read-only access to the hackathon org, so the upload was refused (403).
|
| 74 |
+
Nothing changed on the Space; it still serves the 2026-06-14 build and is sleeping. A new Space
|
| 75 |
+
under HomesteaderLabs returned 402: Gradio on free CPU needs PRO.
|
| 76 |
+
|
| 77 |
+
- [non-production] Get write access back on `build-small-hackathon`, or choose a new home
|
| 78 |
+
(PRO for HomesteaderLabs, or a different host), then re-run `deploy.py`.
|
| 79 |
+
- [non-production] Until then, decide whether the live Space should keep serving the shortcut.
|
app.py
CHANGED
|
@@ -15,7 +15,8 @@ import time
|
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
|
| 18 |
-
from pipeline.convergence import build_result
|
|
|
|
| 19 |
from pipeline.infer import Pipeline
|
| 20 |
from game.ui import build_game_tab, build_stump_tab
|
| 21 |
|
|
@@ -73,7 +74,17 @@ def _loading() -> str:
|
|
| 73 |
def _card(r) -> str:
|
| 74 |
label, color, glyph = TIER.get(r.safety, TIER["UNKNOWN"])
|
| 75 |
|
| 76 |
-
if r.abstained:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
color, label, glyph = INK_UNK, "UNKNOWN", "?"
|
| 78 |
title, sub = "UNKNOWN", "not confident enough β refusing by default"
|
| 79 |
rows = [
|
|
@@ -112,14 +123,20 @@ def _card(r) -> str:
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
| 115 |
-
def identify(image):
|
| 116 |
-
"""Generator: show the vine animation, then the readout.
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
yield _idle()
|
| 119 |
return
|
| 120 |
t0 = time.time()
|
| 121 |
yield _loading()
|
| 122 |
-
result =
|
| 123 |
elapsed = time.time() - t0
|
| 124 |
if elapsed < 1.6: # let the vine finish drawing
|
| 125 |
time.sleep(1.6 - elapsed)
|
|
@@ -358,6 +375,9 @@ with gr.Blocks(title="Forager's Field Station") as demo:
|
|
| 358 |
with gr.Column(scale=1):
|
| 359 |
img = gr.Image(type="pil", label="SPECIMEN", sources=["upload", "webcam"],
|
| 360 |
elem_classes="eink-input", height=300)
|
|
|
|
|
|
|
|
|
|
| 361 |
btn = gr.Button("βΈ SCAN SPECIMEN", variant="primary", elem_classes="eink-scan")
|
| 362 |
if os.path.isdir(EXAMPLES_DIR):
|
| 363 |
samples = [[os.path.join(EXAMPLES_DIR, f)] for f in (
|
|
@@ -371,8 +391,9 @@ with gr.Blocks(title="Forager's Field Station") as demo:
|
|
| 371 |
gr.HTML(QUOTE_BAR)
|
| 372 |
gr.Markdown(SAFETY_NOTICE, elem_id="notice")
|
| 373 |
|
| 374 |
-
btn.click(identify, inputs=img, outputs=out)
|
| 375 |
-
img.change(identify, inputs=img, outputs=out)
|
|
|
|
| 376 |
|
| 377 |
with gr.Tab("β BEAT THE MACHINE"):
|
| 378 |
build_game_tab(PIPE)
|
|
|
|
| 15 |
|
| 16 |
import gradio as gr
|
| 17 |
|
| 18 |
+
from pipeline.convergence import build_result # noqa: F401 (re-exported for callers)
|
| 19 |
+
from pipeline.session import resolve_session
|
| 20 |
from pipeline.infer import Pipeline
|
| 21 |
from game.ui import build_game_tab, build_stump_tab
|
| 22 |
|
|
|
|
| 74 |
def _card(r) -> str:
|
| 75 |
label, color, glyph = TIER.get(r.safety, TIER["UNKNOWN"])
|
| 76 |
|
| 77 |
+
if r.abstained and r.reason == "need_second_photo":
|
| 78 |
+
# Leaning toward an edible ID, but the safety rule withholds it until a
|
| 79 |
+
# second photo confirms. Not a failure β a request.
|
| 80 |
+
color, label, glyph = INK_UNK, "SECOND PHOTO", "β"
|
| 81 |
+
title, sub = "ADD A SECOND ANGLE", "an edible ID needs two photos that agree"
|
| 82 |
+
rows = [
|
| 83 |
+
f"<div class='rdt-row'>{r.key_diff}</div>",
|
| 84 |
+
f"<div class='rdt-meta'>photograph the same specimen from another angle, "
|
| 85 |
+
f"then scan again</div>",
|
| 86 |
+
]
|
| 87 |
+
elif r.abstained:
|
| 88 |
color, label, glyph = INK_UNK, "UNKNOWN", "?"
|
| 89 |
title, sub = "UNKNOWN", "not confident enough β refusing by default"
|
| 90 |
rows = [
|
|
|
|
| 123 |
)
|
| 124 |
|
| 125 |
|
| 126 |
+
def identify(image, image2=None):
|
| 127 |
+
"""Generator: show the vine animation, then the readout.
|
| 128 |
+
|
| 129 |
+
Takes an optional second photo. The safety rule (pipeline.session) means a
|
| 130 |
+
single photo never surfaces an edible verdict β it asks for the second β so
|
| 131 |
+
both inputs feed one resolve_session call.
|
| 132 |
+
"""
|
| 133 |
+
images = [im for im in (image, image2) if im is not None]
|
| 134 |
+
if not images:
|
| 135 |
yield _idle()
|
| 136 |
return
|
| 137 |
t0 = time.time()
|
| 138 |
yield _loading()
|
| 139 |
+
result = resolve_session(PIPE, images)
|
| 140 |
elapsed = time.time() - t0
|
| 141 |
if elapsed < 1.6: # let the vine finish drawing
|
| 142 |
time.sleep(1.6 - elapsed)
|
|
|
|
| 375 |
with gr.Column(scale=1):
|
| 376 |
img = gr.Image(type="pil", label="SPECIMEN", sources=["upload", "webcam"],
|
| 377 |
elem_classes="eink-input", height=300)
|
| 378 |
+
img2 = gr.Image(type="pil", label="SECOND ANGLE β confirms an edible ID",
|
| 379 |
+
sources=["upload", "webcam"], elem_classes="eink-input",
|
| 380 |
+
height=180)
|
| 381 |
btn = gr.Button("βΈ SCAN SPECIMEN", variant="primary", elem_classes="eink-scan")
|
| 382 |
if os.path.isdir(EXAMPLES_DIR):
|
| 383 |
samples = [[os.path.join(EXAMPLES_DIR, f)] for f in (
|
|
|
|
| 391 |
gr.HTML(QUOTE_BAR)
|
| 392 |
gr.Markdown(SAFETY_NOTICE, elem_id="notice")
|
| 393 |
|
| 394 |
+
btn.click(identify, inputs=[img, img2], outputs=out)
|
| 395 |
+
img.change(identify, inputs=[img, img2], outputs=out)
|
| 396 |
+
img2.change(identify, inputs=[img, img2], outputs=out)
|
| 397 |
|
| 398 |
with gr.Tab("β BEAT THE MACHINE"):
|
| 399 |
build_game_tab(PIPE)
|
pipeline/convergence.py
CHANGED
|
@@ -37,6 +37,9 @@ class ForagerResult:
|
|
| 37 |
expert_model: str
|
| 38 |
abstained: bool
|
| 39 |
reason: str # why we abstained (or "" when committed)
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
@property
|
| 42 |
def is_deadly(self) -> bool:
|
|
@@ -71,7 +74,7 @@ def _commit(domain: str, c: dict) -> ForagerResult:
|
|
| 71 |
domain=domain, species=species, scientific_name=meta["scientific"], confidence=conf,
|
| 72 |
safety=meta["safety"], lookalike=meta["lookalike"], key_diff=meta["key_diff"],
|
| 73 |
low_confidence=conf < LOW_CONFIDENCE_THRESHOLD, expert_model=c["expert"],
|
| 74 |
-
abstained=False, reason="",
|
| 75 |
)
|
| 76 |
|
| 77 |
|
|
|
|
| 37 |
expert_model: str
|
| 38 |
abstained: bool
|
| 39 |
reason: str # why we abstained (or "" when committed)
|
| 40 |
+
energy: float | None = None # winning call's OOD energy β telemetry,
|
| 41 |
+
# not a gate (energy doesn't separate
|
| 42 |
+
# deadly-as-safe); kept for drift monitoring.
|
| 43 |
|
| 44 |
@property
|
| 45 |
def is_deadly(self) -> bool:
|
|
|
|
| 74 |
domain=domain, species=species, scientific_name=meta["scientific"], confidence=conf,
|
| 75 |
safety=meta["safety"], lookalike=meta["lookalike"], key_diff=meta["key_diff"],
|
| 76 |
low_confidence=conf < LOW_CONFIDENCE_THRESHOLD, expert_model=c["expert"],
|
| 77 |
+
abstained=False, reason="", energy=c.get("energy"),
|
| 78 |
)
|
| 79 |
|
| 80 |
|
pipeline/infer.py
CHANGED
|
@@ -122,6 +122,11 @@ class Pipeline:
|
|
| 122 |
idx = int(probs.argmax())
|
| 123 |
return self._classes[name][idx], float(probs[idx]), _energy(logits)
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def identify(self, img: Image.Image) -> dict:
|
| 126 |
"""
|
| 127 |
Returns a dict describing the call:
|
|
@@ -148,3 +153,55 @@ class Pipeline:
|
|
| 148 |
|
| 149 |
call = {"expert": ename, "species": species, "confidence": conf, "energy": round(energy, 4)}
|
| 150 |
return {**out, "abstain": False, "calls": [call]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
idx = int(probs.argmax())
|
| 123 |
return self._classes[name][idx], float(probs[idx]), _energy(logits)
|
| 124 |
|
| 125 |
+
def _run_probs(self, name: str, x: np.ndarray) -> tuple[np.ndarray, float]:
|
| 126 |
+
"""Returns (full softmax prob vector, energy) β for multi-photo fusion."""
|
| 127 |
+
logits = self._sessions[name].run(None, {"input": x})[0][0]
|
| 128 |
+
return _softmax(logits), _energy(logits)
|
| 129 |
+
|
| 130 |
def identify(self, img: Image.Image) -> dict:
|
| 131 |
"""
|
| 132 |
Returns a dict describing the call:
|
|
|
|
| 153 |
|
| 154 |
call = {"expert": ename, "species": species, "confidence": conf, "energy": round(energy, 4)}
|
| 155 |
return {**out, "abstain": False, "calls": [call]}
|
| 156 |
+
|
| 157 |
+
def identify_many(self, images: list[Image.Image]) -> dict:
|
| 158 |
+
"""Fuse K photos of one specimen into a single call.
|
| 159 |
+
|
| 160 |
+
Late-fusion by GEOMETRIC MEAN of the experts' softmax vectors (treats
|
| 161 |
+
each photo as independent evidence: agreement sharpens the peak,
|
| 162 |
+
disagreement flattens it), plus a CONSENSUS requirement β every photo's
|
| 163 |
+
own top class must match the fused top class. This is the K>=2 protocol
|
| 164 |
+
validated in scripts/multiview_smoketest.py (deadly-shown-edible -> 0.00%
|
| 165 |
+
at K=2). K=1 degenerates exactly to identify()'s single call.
|
| 166 |
+
|
| 167 |
+
Abstains (and says why) when the photos disagree on domain or class, so
|
| 168 |
+
a confident-but-wrong single frame can't carry an edible verdict.
|
| 169 |
+
"""
|
| 170 |
+
if not images:
|
| 171 |
+
return {"domain": "unknown", "domain_confidence": 0.0,
|
| 172 |
+
"abstain": True, "reason": "low_confidence"}
|
| 173 |
+
if len(images) == 1:
|
| 174 |
+
return self.identify(images[0])
|
| 175 |
+
|
| 176 |
+
# ββ Stage 1: every photo must route to the SAME in-domain domain ββββββ
|
| 177 |
+
routed = [self._run(ROUTER, preprocess(img)) for img in images]
|
| 178 |
+
domains = {d for d, _, _ in routed}
|
| 179 |
+
dconf = min(c for _, c, _ in routed)
|
| 180 |
+
domain = next(iter(domains))
|
| 181 |
+
out = {"domain": domain if len(domains) == 1 else "mixed",
|
| 182 |
+
"domain_confidence": dconf}
|
| 183 |
+
if len(domains) != 1 or dconf < ROUTER_CONFIDENCE_THRESHOLD or domain not in DOMAIN_EXPERTS:
|
| 184 |
+
reason = ("off_domain" if len(domains) == 1 and domain not in DOMAIN_EXPERTS
|
| 185 |
+
else "uncertain_domain")
|
| 186 |
+
return {**out, "abstain": True, "reason": reason}
|
| 187 |
+
|
| 188 |
+
# ββ Stage 2: run the domain's expert on each photo, fuse, check consensus
|
| 189 |
+
ename = DOMAIN_EXPERTS[domain]
|
| 190 |
+
classes = self._classes[ename]
|
| 191 |
+
per_photo = [self._run_probs(ename, preprocess(img)) for img in images]
|
| 192 |
+
prob_rows = np.stack([p for p, _ in per_photo]) # [K, C]
|
| 193 |
+
energy = float(np.mean([e for _, e in per_photo]))
|
| 194 |
+
|
| 195 |
+
logp = np.log(np.clip(prob_rows, 1e-9, 1.0)).mean(axis=0) # geometric mean
|
| 196 |
+
fused = np.exp(logp - logp.max())
|
| 197 |
+
fused /= fused.sum()
|
| 198 |
+
top = int(fused.argmax())
|
| 199 |
+
|
| 200 |
+
per_photo_top = {int(row.argmax()) for row in prob_rows}
|
| 201 |
+
if per_photo_top != {top}:
|
| 202 |
+
# Photos disagree on the species β refuse rather than commit.
|
| 203 |
+
return {**out, "abstain": True, "reason": "no_consensus"}
|
| 204 |
+
|
| 205 |
+
call = {"expert": ename, "species": classes[top],
|
| 206 |
+
"confidence": float(fused[top]), "energy": round(energy, 4)}
|
| 207 |
+
return {**out, "abstain": False, "calls": [call]}
|
pipeline/session.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""session.py β the two-photo safety policy that sits over the resolver.
|
| 2 |
+
|
| 3 |
+
The core safety rule, stated plainly:
|
| 4 |
+
|
| 5 |
+
You are never shown an edible verdict on the strength of a single photo.
|
| 6 |
+
|
| 7 |
+
A single photo can show DEADLY (over-warning is always safe) or abstain. A
|
| 8 |
+
SAFE/CAUTION identification is only surfaced when a SECOND photo agrees with the
|
| 9 |
+
first (geometric-mean fusion + consensus, in infer.identify_many). So the second
|
| 10 |
+
photo is requested exactly, and only, when the system is leaning toward telling
|
| 11 |
+
you something is edible β the one moment extra rigor is worth the friction.
|
| 12 |
+
|
| 13 |
+
This is what closes the ~1% single-shot deadly-shown-edible residual to 0.00%
|
| 14 |
+
(scripts/multiview_smoketest.py).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from PIL import Image
|
| 18 |
+
|
| 19 |
+
from .convergence import ForagerResult, build_result
|
| 20 |
+
from .infer import Pipeline
|
| 21 |
+
|
| 22 |
+
# There is no confidence bar that lets one photo commit edible. A 0.85 bar used to:
|
| 23 |
+
# the val sets had put every single-photo deadly-as-edible miss at <= 0.772. Over
|
| 24 |
+
# the full val sets, two toxic mushroom photos commit as SAFE above it (lion's
|
| 25 |
+
# mane at 0.91, chanterelle at 0.87), and val is the optimistic case. A confident
|
| 26 |
+
# wrong answer looks exactly like a confident right one, so no threshold on one
|
| 27 |
+
# photo's confidence can hold; only a second look can.
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def resolve_session(pipe: Pipeline, images: list[Image.Image]) -> ForagerResult:
|
| 31 |
+
"""Run the K-photo pipeline and apply the single-photo edible gate.
|
| 32 |
+
|
| 33 |
+
- 0 photos -> abstain.
|
| 34 |
+
- DEADLY (any photo count) -> surface it. Over-warning is safe.
|
| 35 |
+
- 1 photo, SAFE/CAUTION, at any confidence -> withhold; ask for a second photo.
|
| 36 |
+
- 2+ photos -> the fused/consensus verdict from identify_many stands.
|
| 37 |
+
"""
|
| 38 |
+
result = build_result(pipe.identify_many(images))
|
| 39 |
+
|
| 40 |
+
if (len(images) == 1
|
| 41 |
+
and not result.abstained
|
| 42 |
+
and result.safety in ("SAFE", "CAUTION")):
|
| 43 |
+
return _need_second_photo(result)
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _need_second_photo(provisional: ForagerResult) -> ForagerResult:
|
| 48 |
+
"""Turn a single-photo edible lean into a 'add a second photo' refusal,
|
| 49 |
+
carrying the provisional species so the UI can say what it's leaning toward
|
| 50 |
+
without committing to it."""
|
| 51 |
+
return ForagerResult(
|
| 52 |
+
domain=provisional.domain,
|
| 53 |
+
species="unknown",
|
| 54 |
+
scientific_name=provisional.scientific_name,
|
| 55 |
+
confidence=provisional.confidence,
|
| 56 |
+
safety="UNKNOWN",
|
| 57 |
+
lookalike=provisional.lookalike,
|
| 58 |
+
key_diff=(f"Leaning {provisional.species.replace('_', ' ')}, but an edible call "
|
| 59 |
+
f"needs a second photo from a different angle to confirm."),
|
| 60 |
+
low_confidence=True,
|
| 61 |
+
expert_model=provisional.expert_model,
|
| 62 |
+
abstained=True,
|
| 63 |
+
reason="need_second_photo",
|
| 64 |
+
)
|
tests/test_two_photo.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Two-photo safety protocol tests, against the real ONNX models.
|
| 2 |
+
|
| 3 |
+
The load-bearing guarantee: no edible verdict is ever surfaced from a single
|
| 4 |
+
photo, and two disagreeing photos abstain rather than commit.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
import pytest
|
| 11 |
+
from PIL import Image
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
| 14 |
+
|
| 15 |
+
from pipeline.infer import Pipeline # noqa: E402
|
| 16 |
+
from pipeline.session import resolve_session # noqa: E402
|
| 17 |
+
|
| 18 |
+
ML = "/home/alex/Documents/Forager/forager_ml"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.fixture(scope="module")
|
| 22 |
+
def pipe():
|
| 23 |
+
return Pipeline()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _img(path):
|
| 27 |
+
return Image.open(path)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _first(cls_dir, n=1):
|
| 31 |
+
files = sorted(f for f in os.listdir(cls_dir)
|
| 32 |
+
if f.lower().endswith((".jpg", ".jpeg", ".png")))
|
| 33 |
+
return [os.path.join(cls_dir, f) for f in files[:n]]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ββ Policy: single photo never surfaces an edible verdict ββββββββββββββββββββ
|
| 37 |
+
|
| 38 |
+
def test_single_photo_never_commits_edible(pipe):
|
| 39 |
+
"""A single photo never surfaces an edible (SAFE/CAUTION) verdict, at any
|
| 40 |
+
confidence: it is withheld and routed to a second photo."""
|
| 41 |
+
safe_dir = f"{ML}/berry_dataset_split/val/blackberry_common"
|
| 42 |
+
if not os.path.isdir(safe_dir):
|
| 43 |
+
pytest.skip("val data not present")
|
| 44 |
+
for path in _first(safe_dir, 12):
|
| 45 |
+
r = resolve_session(pipe, [_img(path)])
|
| 46 |
+
assert not (not r.abstained and r.safety in ("SAFE", "CAUTION")), (
|
| 47 |
+
f"single photo committed {r.safety} at {r.confidence:.3f} for {path}")
|
| 48 |
+
if r.abstained:
|
| 49 |
+
assert r.reason in ("need_second_photo", "low_confidence",
|
| 50 |
+
"uncertain_domain", "off_domain")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_confident_deadly_mushroom_is_not_shown_safe(pipe):
|
| 54 |
+
"""The case that removed the 0.85 shortcut: toxic-catch-all photos the
|
| 55 |
+
mushroom expert calls lion's mane (0.91) and chanterelle (0.87)."""
|
| 56 |
+
d = f"{ML}/high_value_dataset_split/val/high_value_toxics"
|
| 57 |
+
paths = [f"{d}/599814795.jpg", f"{d}/616602478.jpg"]
|
| 58 |
+
if not all(os.path.isfile(p) for p in paths):
|
| 59 |
+
pytest.skip("val data not present")
|
| 60 |
+
for path in paths:
|
| 61 |
+
r = resolve_session(pipe, [_img(path)])
|
| 62 |
+
assert r.abstained and r.reason == "need_second_photo", (
|
| 63 |
+
f"{path} surfaced as {r.species} {r.safety} at {r.confidence:.3f}")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_two_agreeing_photos_can_commit_safe(pipe):
|
| 67 |
+
"""Two photos of the same SAFE class should be allowed to commit (the whole
|
| 68 |
+
point β the second photo unlocks an edible verdict)."""
|
| 69 |
+
safe_dir = f"{ML}/berry_dataset_split/val/blackberry_common"
|
| 70 |
+
if not os.path.isdir(safe_dir):
|
| 71 |
+
pytest.skip("val data not present")
|
| 72 |
+
committed = 0
|
| 73 |
+
paths = _first(safe_dir, 12)
|
| 74 |
+
# try a few pairs; at least one agreeing pair should commit SAFE
|
| 75 |
+
for i in range(0, len(paths) - 1, 2):
|
| 76 |
+
r = resolve_session(pipe, [_img(paths[i]), _img(paths[i + 1])])
|
| 77 |
+
if not r.abstained and r.safety == "SAFE":
|
| 78 |
+
committed += 1
|
| 79 |
+
assert committed >= 1, "no agreeing SAFE pair ever committed β fusion too strict"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_deadly_can_surface_on_single_photo(pipe):
|
| 83 |
+
"""Over-warning is safe: a DEADLY read on one photo is allowed through."""
|
| 84 |
+
deadly_dir = f"{ML}/berry_dataset_split/val/pokeweed_toxic"
|
| 85 |
+
if not os.path.isdir(deadly_dir):
|
| 86 |
+
pytest.skip("val data not present")
|
| 87 |
+
# We don't require every pokeweed to read DEADLY on one shot, only that the
|
| 88 |
+
# policy PERMITS a single-photo DEADLY (never converts it to need_second).
|
| 89 |
+
for path in _first(deadly_dir, 8):
|
| 90 |
+
r = resolve_session(pipe, [_img(path)])
|
| 91 |
+
if r.safety == "DEADLY":
|
| 92 |
+
assert not r.abstained
|
| 93 |
+
return
|
| 94 |
+
pytest.skip("no single-shot DEADLY in sample (still safe: they abstained)")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_two_photos_no_deadly_shown_edible(pipe):
|
| 98 |
+
"""The core claim: fuse two photos of DEADLY berry specimens; none may be
|
| 99 |
+
surfaced as edible (SAFE/CAUTION committed)."""
|
| 100 |
+
for cls in ("pokeweed_toxic", "canada_moonseed_deadly"):
|
| 101 |
+
d = f"{ML}/berry_dataset_split/val/{cls}"
|
| 102 |
+
if not os.path.isdir(d):
|
| 103 |
+
pytest.skip("val data not present")
|
| 104 |
+
paths = _first(d, 12)
|
| 105 |
+
for i in range(0, len(paths) - 1, 2):
|
| 106 |
+
r = resolve_session(pipe, [_img(paths[i]), _img(paths[i + 1])])
|
| 107 |
+
assert not (not r.abstained and r.safety in ("SAFE", "CAUTION")), (
|
| 108 |
+
f"{cls} pair surfaced as {r.safety} β toxic-as-edible at K=2")
|