From 6c48834268a1485a76b12429a88a6057ca242276 Mon Sep 17 00:00:00 2001 From: wzd8888 Date: Wed, 14 Oct 2026 06:35:52 +0800 Subject: [PATCH] Skip taught boxes whose image is missing on disk An object sensor builds its taught index from the stored images of its taught boxes. When one of those images has disappeared (a partly restored data directory, an external clean-up), imaging.load raised FileNotFoundError and the whole index build failed, so the sensor stopped matching anything at all. Skip the boxes whose image is gone and build the index from the ones still there, logging which were dropped. This matches how the rest of the app already tolerates a missing image (see imaging.load, and the import path that skips a broken sample). Fixes #45 Signed-off-by: wzd8888 --- visionstate/backend/tests/test_teach.py | 31 +++++++++++++++++++ .../backend/visionstate/engine/teaching.py | 6 ++++ 2 files changed, 37 insertions(+) diff --git a/visionstate/backend/tests/test_teach.py b/visionstate/backend/tests/test_teach.py index c5552a8..207b906 100644 --- a/visionstate/backend/tests/test_teach.py +++ b/visionstate/backend/tests/test_teach.py @@ -275,6 +275,37 @@ def test_an_index_built_before_an_own_label_existed_is_rebuilt(settings): # noq db.engine.dispose() +def test_a_taught_box_whose_image_is_gone_is_skipped(settings): # noqa: F811 + """A taught box whose stored image has disappeared (a partly restored data dir, an external + clean-up) must not take the whole sensor down: it is skipped, like a broken upload, and the + index is built from the boxes that are still there.""" + import asyncio + + from PIL import Image + + from visionstate.db import Database, Sample, Sensor + from visionstate.engine import Runtime + + settings.ensure_dirs() + db = Database(settings.db_path) + db.init() + rt = Runtime(settings, db) + rt.embedder = FakeEmbedder() + with db.session() as s: + row = Sensor(slug="drive", name="Drive", kind=KIND_OBJECTS, source_type="http", source="x", interval_s=5, + threshold=0.6, debounce=1, objects={"classes": ["dog"]}) # fmt: skip + s.add(row) + s.flush() + sid = row.id + kept = rt.add_object_example(sid, Image.new("RGB", (64, 64)), [0.1, 0.1, 0.5, 0.5], "dog", "dog", 0.9, "live") + gone = rt.add_object_example(sid, Image.new("RGB", (64, 64)), [0.5, 0.5, 0.9, 0.9], "dog", "dog", 0.9, "live") + with db.session() as s: + rt.storage.sample_path(sid, s.get(Sample, gone).filename).unlink() + index = asyncio.run(rt.taught_index(rt.load_sensor(sid))) # would raise FileNotFoundError before + assert index is not None and index.ids == [kept] + db.engine.dispose() + + # --- the API, with the real detector and backbone on CC0 photos ---------------------------- diff --git a/visionstate/backend/visionstate/engine/teaching.py b/visionstate/backend/visionstate/engine/teaching.py index b1c21dc..8ab4931 100644 --- a/visionstate/backend/visionstate/engine/teaching.py +++ b/visionstate/backend/visionstate/engine/teaching.py @@ -48,6 +48,12 @@ def _build_taught(self, cfg: SensorConfig, generation: int = 0) -> teach.TaughtI select(Sample).where(Sample.sensor_id == cfg.id, Sample.object_label.is_not(None)).order_by(Sample.id) ) examples = [x for x in rows if x.object_label is not None and teach.usable(x.object_label, parents)] + # A taught box whose image has vanished (a partly restored data dir, an external clean-up) + # must not take the whole sensor down: it is skipped, like a broken upload. + gone = [x.id for x in examples if not self.storage.sample_path(cfg.id, x.filename).exists()] + if gone: + log.warning("Sensor %s: skipping %d taught box(es) with no image on disk: %s", cfg.slug, len(gone), gone) + examples = [x for x in examples if x.id not in set(gone)] vectors = self._taught_vectors(cfg, examples) return teach.build( embedder.spec.id,