From 30d3c7d8739839ef8c4f9dfa091b2f90649d0d6a Mon Sep 17 00:00:00 2001 From: NoiceHax Date: Sat, 15 Aug 2026 15:12:59 +0530 Subject: [PATCH 1/2] fix: skip an image OpenCV cannot decode while tagging get_classes returns None when cv2.imread fails, and len(None) raised out of the loop, so the pass stopped at the first unreadable file. Every image behind it stayed untagged, and each later run died on the same file. --- backend/app/utils/images.py | 7 +++++ backend/tests/test_tagging_pipeline.py | 40 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 backend/tests/test_tagging_pipeline.py diff --git a/backend/app/utils/images.py b/backend/app/utils/images.py index 0eafb5fdd..563bc6655 100644 --- a/backend/app/utils/images.py +++ b/backend/app/utils/images.py @@ -223,6 +223,13 @@ def image_util_classify_and_face_detect_images( # Step 1: Get classes classes = object_classifier.get_classes(image_path) + # None means OpenCV could not decode the file. Skip it: len(None) + # would raise and abort the pass, leaving every image after this + # one untagged and the folder stuck on "processing" forever. + if classes is None: + logger.warning(f"Skipping unreadable image: {image_path}") + continue + # Step 2: Insert class-image pairs if classes were detected if len(classes) > 0: # Create image-class pairs diff --git a/backend/tests/test_tagging_pipeline.py b/backend/tests/test_tagging_pipeline.py new file mode 100644 index 000000000..7da06cc74 --- /dev/null +++ b/backend/tests/test_tagging_pipeline.py @@ -0,0 +1,40 @@ +""" +One image OpenCV cannot decode used to abort the whole AI tagging pass, so +every image queued behind it stayed untagged. These cover the skip. +""" + +from unittest.mock import patch + +from app.utils.images import image_util_classify_and_face_detect_images + + +def _image(image_id: str) -> dict: + return {"id": image_id, "path": f"/photos/{image_id}.jpg"} + + +class TestClassifyAndFaceDetectImages: + @patch("app.utils.images.db_update_image_tagged_status") + @patch("app.utils.images.db_insert_image_classes_batch") + @patch("app.utils.images.FaceDetector") + @patch("app.utils.images.ObjectClassifier") + def test_unreadable_image_skipped_and_later_images_still_tagged( + self, + mock_classifier_cls, + mock_detector_cls, + mock_insert_classes, + mock_update_tagged, + ): + def get_classes(image_path): + # img1 is the unreadable one -- cv2.imread returns None for it, so + # the classifier returns None instead of a list of class ids. + return None if "img1" in image_path else [17] + + mock_classifier_cls.return_value.get_classes.side_effect = get_classes + + images = [_image("img0"), _image("img1"), _image("img2")] + + image_util_classify_and_face_detect_images(images) + + tagged = [call.args[0] for call in mock_update_tagged.call_args_list] + assert tagged == ["img0", "img2"] + assert mock_insert_classes.call_count == 2 From 0c27210032493e3844462115249e3cece5e0a0a2 Mon Sep 17 00:00:00 2001 From: NoiceHax Date: Sat, 15 Aug 2026 19:17:53 +0530 Subject: [PATCH 2/2] test: assert the exact tagging calls, and cover the face detection path The old assertions only counted calls, so they would have passed with class records attached to the wrong image, and they ignored the tagged status argument. No image returned class 0 either, so face detection was never exercised. Also adds a case for the warning on an unreadable image. --- backend/tests/test_tagging_pipeline.py | 52 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/backend/tests/test_tagging_pipeline.py b/backend/tests/test_tagging_pipeline.py index 7da06cc74..7881ed874 100644 --- a/backend/tests/test_tagging_pipeline.py +++ b/backend/tests/test_tagging_pipeline.py @@ -3,7 +3,7 @@ every image queued behind it stayed untagged. These cover the skip. """ -from unittest.mock import patch +from unittest.mock import call, patch from app.utils.images import image_util_classify_and_face_detect_images @@ -23,18 +23,58 @@ def test_unreadable_image_skipped_and_later_images_still_tagged( mock_detector_cls, mock_insert_classes, mock_update_tagged, - ): + ) -> None: def get_classes(image_path): # img1 is the unreadable one -- cv2.imread returns None for it, so # the classifier returns None instead of a list of class ids. - return None if "img1" in image_path else [17] + if "img1" in image_path: + return None + # class 0 is "person", which is what sends img2 to face detection. + return [0, 17] if "img2" in image_path else [17] mock_classifier_cls.return_value.get_classes.side_effect = get_classes + mock_detect_faces = mock_detector_cls.return_value.detect_faces + mock_detect_faces.return_value = {"faces_skipped": 0} images = [_image("img0"), _image("img1"), _image("img2")] image_util_classify_and_face_detect_images(images) - tagged = [call.args[0] for call in mock_update_tagged.call_args_list] - assert tagged == ["img0", "img2"] - assert mock_insert_classes.call_count == 2 + # Only the readable images are marked tagged, and the status argument + # matters: passing False here would leave them queued forever. + assert mock_update_tagged.call_args_list == [ + call("img0", True), + call("img2", True), + ] + + # Assert the pairs themselves, not just how many calls were made, so a + # class attached to the wrong image is caught. + assert mock_insert_classes.call_args_list == [ + call([("img0", 17)]), + call([("img2", 0), ("img2", 17)]), + ] + + # img1 never reaches face detection, and img2 does because it has a + # person in it. + assert mock_detect_faces.call_args_list == [ + call("img2", "/photos/img2.jpg") + ] + + @patch("app.utils.images.db_update_image_tagged_status") + @patch("app.utils.images.db_insert_image_classes_batch") + @patch("app.utils.images.FaceDetector") + @patch("app.utils.images.ObjectClassifier") + def test_unreadable_image_is_logged( + self, + mock_classifier_cls, + mock_detector_cls, + mock_insert_classes, + mock_update_tagged, + caplog, + ) -> None: + mock_classifier_cls.return_value.get_classes.return_value = None + + image_util_classify_and_face_detect_images([_image("img1")]) + + assert "/photos/img1.jpg" in caplog.text + mock_update_tagged.assert_not_called()