From e7f7fd1c91d9d96e19cddd108fed96ce57bc56c8 Mon Sep 17 00:00:00 2001 From: ildyria Date: Mon, 6 Apr 2026 21:28:01 +0200 Subject: [PATCH 01/36] squash --- .dockerignore | 6 + .github/dependabot.yml | 3 - .github/workflows/CICD.yml | 2 + .github/workflows/dependency-review.yml | 5 + .../python_ai_vision_face_recognition.yml | 154 ++ .gitignore | 2 + Makefile | 3 + .../face-recognition/.insightface/.gitignore | 2 + ai-vision-service/face-recognition/Dockerfile | 54 + ai-vision-service/face-recognition/README.md | 146 ++ .../face-recognition/app/__init__.py | 1 + .../face-recognition/app/api/__init__.py | 1 + .../face-recognition/app/api/dependencies.py | 42 + .../face-recognition/app/api/routes.py | 553 +++++ .../face-recognition/app/api/schemas.py | 267 +++ .../app/clustering/__init__.py | 1 + .../app/clustering/clusterer.py | 80 + .../face-recognition/app/config.py | 128 ++ .../app/detection/__init__.py | 1 + .../face-recognition/app/detection/cropper.py | 180 ++ .../app/detection/detector.py | 230 +++ .../app/embeddings/__init__.py | 1 + .../app/embeddings/factory.py | 42 + .../app/embeddings/pgvector_store.py | 205 ++ .../app/embeddings/sqlite_store.py | 243 +++ .../face-recognition/app/embeddings/store.py | 103 + .../face-recognition/app/main.py | 126 ++ .../face-recognition/app/matching/__init__.py | 1 + .../face-recognition/app/matching/matcher.py | 62 + .../face-recognition/data/.gitignore | 2 + .../face-recognition/pyproject.toml | 91 + .../face-recognition/tests/__init__.py | 1 + .../face-recognition/tests/conftest.py | 157 ++ .../face-recognition/tests/test_api.py | 181 ++ .../face-recognition/tests/test_clustering.py | 111 ++ .../face-recognition/tests/test_cropper.py | 114 ++ .../face-recognition/tests/test_detection.py | 180 ++ .../face-recognition/tests/test_embeddings.py | 125 ++ .../face-recognition/tests/test_matching.py | 102 + .../face-recognition/tests/test_smoke.py | 112 ++ ai-vision-service/face-recognition/uv.lock | 1776 +++++++++++++++++ app/Actions/Diagnostics/Errors.php | 2 + .../Pipes/Checks/AiVisionServiceCheck.php | 132 ++ app/Actions/Photo/Create.php | 1 + .../Standalone/AutoScanFacesOnUpload.php | 49 + app/Console/Commands/RescanFailedFaces.php | 83 + app/Console/Commands/ScanFaces.php | 67 + app/Contracts/Http/Requests/HasFace.php | 16 + app/Contracts/Http/Requests/HasPerson.php | 16 + app/Enum/FacePermissionMode.php | 17 + app/Enum/FaceScanStatus.php | 16 + app/Factories/PersonFactory.php | 34 + .../Admin/Maintenance/BulkScanFaces.php | 71 + .../Maintenance/DestroyDismissedFaces.php | 66 + .../Admin/Maintenance/ResetFaceScanStatus.php | 75 + .../Admin/Maintenance/ResetStuckFaces.php | 59 + .../Admin/Maintenance/RunFaceClustering.php | 70 + .../Admin/Maintenance/SyncFaceEmbeddings.php | 100 + .../AiVision/AlbumPeopleController.php | 63 + .../AiVision/FaceClusterController.php | 103 + .../Controllers/AiVision/FaceController.php | 129 ++ .../AiVision/FaceDetectionController.php | 329 +++ .../Controllers/AiVision/PeopleController.php | 113 ++ .../AiVision/PersonClaimController.php | 94 + .../AiVision/PersonPhotosController.php | 81 + .../AiVision/SelfieClaimController.php | 104 + app/Http/Middleware/ConfigIntegrity.php | 7 + app/Http/Middleware/VerifyCsrfToken.php | 2 + app/Http/Requests/Face/AssignFaceRequest.php | 71 + app/Http/Requests/Face/BatchFaceRequest.php | 64 + app/Http/Requests/Face/BulkScanRequest.php | 58 + .../Requests/Face/ClusterAssignRequest.php | 39 + .../Requests/Face/ClusterDismissRequest.php | 22 + .../Requests/Face/ClusterIndexRequest.php | 22 + .../Requests/Face/ClusterResultsRequest.php | 75 + .../Face/DestroyDismissedFacesRequest.php | 22 + .../Face/FaceDetectionResultsRequest.php | 105 + app/Http/Requests/Face/ScanPhotosRequest.php | 92 + .../Requests/Face/ToggleDismissedRequest.php | 52 + .../Requests/Face/UnclusterFacesRequest.php | 37 + .../Maintenance/ResetStuckFacesRequest.php | 54 + .../Requests/Person/ClaimPersonRequest.php | 71 + .../Requests/Person/DestroyPersonRequest.php | 41 + .../Person/GetAlbumPersonsRequest.php | 67 + .../Requests/Person/ListPersonsRequest.php | 22 + .../Requests/Person/MergePersonRequest.php | 41 + .../Requests/Person/SelfieClaimRequest.php | 44 + .../Requests/Person/ShowPersonRequest.php | 45 + .../Requests/Person/StorePersonRequest.php | 49 + .../Requests/Person/UnclaimPersonRequest.php | 65 + .../Requests/Person/UpdatePersonRequest.php | 71 + app/Http/Requests/Traits/HasFaceTrait.php | 21 + app/Http/Requests/Traits/HasPersonTrait.php | 21 + .../Collections/PaginatedClustersResource.php | 41 + .../Collections/PaginatedPersonsResource.php | 43 + .../Models/ClusterPreviewResource.php | 34 + app/Http/Resources/Models/FaceResource.php | 58 + .../Models/FaceSuggestionResource.php | 30 + app/Http/Resources/Models/PersonResource.php | 66 + app/Http/Resources/Models/PhotoResource.php | 59 + .../Rights/ModulesRightsResource.php | 22 + app/Jobs/DeleteFaceEmbeddingsJob.php | 63 + app/Jobs/DispatchFaceScanJob.php | 84 + app/Models/Face.php | 148 ++ app/Models/FaceSuggestion.php | 72 + app/Models/Person.php | 120 ++ app/Models/Photo.php | 12 + app/Models/User.php | 10 + app/Observers/PhotoObserver.php | 34 + app/Policies/AiVisionPolicy.php | 172 ++ app/Providers/AppServiceProvider.php | 3 + app/Providers/AuthServiceProvider.php | 6 + app/Repositories/PhotoRepository.php | 2 +- .../Image/FacialRecognitionService.php | 148 ++ config/features.php | 15 + database/factories/FaceFactory.php | 72 + database/factories/PersonFactory.php | 49 + ...2026_03_21_000001_create_persons_table.php | 40 + .../2026_03_21_000002_create_faces_table.php | 68 + .../2026_03_21_000003_ai_vision_config.php | 127 ++ ...03_21_000004_create_ai_vision_category.php | 37 + ..._add_representative_face_id_to_persons.php | 42 + ...3_21_000006_add_cluster_label_to_faces.php | 41 + docker-compose.minimal.yaml | 36 + docker-compose.yaml | 68 +- .../2-how-to/configure-facial-recognition.md | 231 +++ .../docker-compose-ai-vision-only.yaml | 43 + docs/specs/3-reference/database-schema.md | 77 +- .../features/030-ai-vision-service/plan.md | 183 +- .../features/030-ai-vision-service/spec.md | 317 ++- .../features/030-ai-vision-service/tasks.md | 335 +++- docs/specs/4-architecture/knowledge-map.md | 45 +- docs/specs/4-architecture/open-questions.md | 463 ++++- docs/specs/4-architecture/roadmap.md | 4 +- lang/en/gallery.php | 2 + lang/en/left-menu.php | 1 + lang/en/maintenance.php | 25 + lang/en/people.php | 77 + .../js/components/drawers/PhotoDetails.vue | 112 ++ .../js/components/gallery/PersonCard.vue | 68 + .../gallery/albumModule/AlbumHero.vue | 13 + .../gallery/albumModule/AlbumPanel.vue | 56 + .../components/gallery/photoModule/Dock.vue | 32 + .../gallery/photoModule/FaceOverlay.vue | 189 ++ .../gallery/photoModule/PhotoBox.vue | 53 +- .../components/gallery/tagModule/TagPanel.vue | 2 + .../maintenance/MaintenanceBulkScanFaces.vue | 83 + .../MaintenanceDestroyDismissedFaces.vue | 88 + .../MaintenanceResetFaceScanStatus.vue | 88 + .../maintenance/MaintenanceRunClustering.vue | 75 + .../MaintenanceSyncFaceEmbeddings.vue | 88 + .../components/modals/FaceAssignmentModal.vue | 205 ++ .../js/components/modals/MergePersonModal.vue | 120 ++ .../js/components/modals/SelfieClaimModal.vue | 136 ++ .../composables/contextMenus/contextMenu.ts | 20 + .../js/composables/contextMenus/leftMenu.ts | 6 + resources/js/lychee.d.ts | 57 + resources/js/router/routes.ts | 19 + resources/js/services/face-cluster-service.ts | 22 + .../js/services/face-detection-service.ts | 51 + resources/js/services/maintenance-service.ts | 30 + resources/js/services/people-service.ts | 50 + resources/js/utils/Helpers.ts | 2 + resources/js/views/Maintenance.vue | 10 + resources/js/views/Profile.vue | 11 + .../js/views/face-recog/FaceClusters.vue | 210 ++ resources/js/views/face-recog/People.vue | 94 + .../js/views/face-recog/PersonDetail.vue | 250 +++ resources/js/views/gallery-panels/Albums.vue | 1 + resources/js/views/gallery-panels/Search.vue | 2 + .../js/views/gallery-panels/Timeline.vue | 1 + routes/api_v2.php | 61 + routes/web_v2.php | 1 + .../AiVision/FaceAssignmentTest.php | 121 ++ .../AiVision/FaceClusterReviewTest.php | 155 ++ .../FaceDetectionServiceUnavailableTest.php | 65 + .../Feature_v2/AiVision/FaceDetectionTest.php | 251 +++ tests/Feature_v2/AiVision/FaceDismissTest.php | 116 ++ .../MaintenanceResetStuckFacesTest.php | 143 ++ .../AiVision/PeopleControllerTest.php | 223 +++ tests/Feature_v2/AiVision/PersonClaimTest.php | 187 ++ .../Feature_v2/AiVision/PersonPhotosTest.php | 82 + .../AiVision/ScanFacesCommandTest.php | 89 + tests/Feature_v2/AiVision/SelfieClaimTest.php | 142 ++ tests/LoadedSubscriber.php | 7 + tests/Unit/Actions/Db/OptimizeTablesTest.php | 2 +- tests/Unit/Models/FaceTest.php | 129 ++ tests/Unit/Models/PersonTest.php | 117 ++ 188 files changed, 16421 insertions(+), 105 deletions(-) create mode 100644 .github/workflows/python_ai_vision_face_recognition.yml create mode 100644 ai-vision-service/face-recognition/.insightface/.gitignore create mode 100644 ai-vision-service/face-recognition/Dockerfile create mode 100644 ai-vision-service/face-recognition/README.md create mode 100644 ai-vision-service/face-recognition/app/__init__.py create mode 100644 ai-vision-service/face-recognition/app/api/__init__.py create mode 100644 ai-vision-service/face-recognition/app/api/dependencies.py create mode 100644 ai-vision-service/face-recognition/app/api/routes.py create mode 100644 ai-vision-service/face-recognition/app/api/schemas.py create mode 100644 ai-vision-service/face-recognition/app/clustering/__init__.py create mode 100644 ai-vision-service/face-recognition/app/clustering/clusterer.py create mode 100644 ai-vision-service/face-recognition/app/config.py create mode 100644 ai-vision-service/face-recognition/app/detection/__init__.py create mode 100644 ai-vision-service/face-recognition/app/detection/cropper.py create mode 100644 ai-vision-service/face-recognition/app/detection/detector.py create mode 100644 ai-vision-service/face-recognition/app/embeddings/__init__.py create mode 100644 ai-vision-service/face-recognition/app/embeddings/factory.py create mode 100644 ai-vision-service/face-recognition/app/embeddings/pgvector_store.py create mode 100644 ai-vision-service/face-recognition/app/embeddings/sqlite_store.py create mode 100644 ai-vision-service/face-recognition/app/embeddings/store.py create mode 100644 ai-vision-service/face-recognition/app/main.py create mode 100644 ai-vision-service/face-recognition/app/matching/__init__.py create mode 100644 ai-vision-service/face-recognition/app/matching/matcher.py create mode 100644 ai-vision-service/face-recognition/data/.gitignore create mode 100644 ai-vision-service/face-recognition/pyproject.toml create mode 100644 ai-vision-service/face-recognition/tests/__init__.py create mode 100644 ai-vision-service/face-recognition/tests/conftest.py create mode 100644 ai-vision-service/face-recognition/tests/test_api.py create mode 100644 ai-vision-service/face-recognition/tests/test_clustering.py create mode 100644 ai-vision-service/face-recognition/tests/test_cropper.py create mode 100644 ai-vision-service/face-recognition/tests/test_detection.py create mode 100644 ai-vision-service/face-recognition/tests/test_embeddings.py create mode 100644 ai-vision-service/face-recognition/tests/test_matching.py create mode 100644 ai-vision-service/face-recognition/tests/test_smoke.py create mode 100644 ai-vision-service/face-recognition/uv.lock create mode 100644 app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceCheck.php create mode 100644 app/Actions/Photo/Pipes/Standalone/AutoScanFacesOnUpload.php create mode 100644 app/Console/Commands/RescanFailedFaces.php create mode 100644 app/Console/Commands/ScanFaces.php create mode 100644 app/Contracts/Http/Requests/HasFace.php create mode 100644 app/Contracts/Http/Requests/HasPerson.php create mode 100644 app/Enum/FacePermissionMode.php create mode 100644 app/Enum/FaceScanStatus.php create mode 100644 app/Factories/PersonFactory.php create mode 100644 app/Http/Controllers/Admin/Maintenance/BulkScanFaces.php create mode 100644 app/Http/Controllers/Admin/Maintenance/DestroyDismissedFaces.php create mode 100644 app/Http/Controllers/Admin/Maintenance/ResetFaceScanStatus.php create mode 100644 app/Http/Controllers/Admin/Maintenance/ResetStuckFaces.php create mode 100644 app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php create mode 100644 app/Http/Controllers/Admin/Maintenance/SyncFaceEmbeddings.php create mode 100644 app/Http/Controllers/AiVision/AlbumPeopleController.php create mode 100644 app/Http/Controllers/AiVision/FaceClusterController.php create mode 100644 app/Http/Controllers/AiVision/FaceController.php create mode 100644 app/Http/Controllers/AiVision/FaceDetectionController.php create mode 100644 app/Http/Controllers/AiVision/PeopleController.php create mode 100644 app/Http/Controllers/AiVision/PersonClaimController.php create mode 100644 app/Http/Controllers/AiVision/PersonPhotosController.php create mode 100644 app/Http/Controllers/AiVision/SelfieClaimController.php create mode 100644 app/Http/Requests/Face/AssignFaceRequest.php create mode 100644 app/Http/Requests/Face/BatchFaceRequest.php create mode 100644 app/Http/Requests/Face/BulkScanRequest.php create mode 100644 app/Http/Requests/Face/ClusterAssignRequest.php create mode 100644 app/Http/Requests/Face/ClusterDismissRequest.php create mode 100644 app/Http/Requests/Face/ClusterIndexRequest.php create mode 100644 app/Http/Requests/Face/ClusterResultsRequest.php create mode 100644 app/Http/Requests/Face/DestroyDismissedFacesRequest.php create mode 100644 app/Http/Requests/Face/FaceDetectionResultsRequest.php create mode 100644 app/Http/Requests/Face/ScanPhotosRequest.php create mode 100644 app/Http/Requests/Face/ToggleDismissedRequest.php create mode 100644 app/Http/Requests/Face/UnclusterFacesRequest.php create mode 100644 app/Http/Requests/Maintenance/ResetStuckFacesRequest.php create mode 100644 app/Http/Requests/Person/ClaimPersonRequest.php create mode 100644 app/Http/Requests/Person/DestroyPersonRequest.php create mode 100644 app/Http/Requests/Person/GetAlbumPersonsRequest.php create mode 100644 app/Http/Requests/Person/ListPersonsRequest.php create mode 100644 app/Http/Requests/Person/MergePersonRequest.php create mode 100644 app/Http/Requests/Person/SelfieClaimRequest.php create mode 100644 app/Http/Requests/Person/ShowPersonRequest.php create mode 100644 app/Http/Requests/Person/StorePersonRequest.php create mode 100644 app/Http/Requests/Person/UnclaimPersonRequest.php create mode 100644 app/Http/Requests/Person/UpdatePersonRequest.php create mode 100644 app/Http/Requests/Traits/HasFaceTrait.php create mode 100644 app/Http/Requests/Traits/HasPersonTrait.php create mode 100644 app/Http/Resources/Collections/PaginatedClustersResource.php create mode 100644 app/Http/Resources/Collections/PaginatedPersonsResource.php create mode 100644 app/Http/Resources/Models/ClusterPreviewResource.php create mode 100644 app/Http/Resources/Models/FaceResource.php create mode 100644 app/Http/Resources/Models/FaceSuggestionResource.php create mode 100644 app/Http/Resources/Models/PersonResource.php create mode 100644 app/Jobs/DeleteFaceEmbeddingsJob.php create mode 100644 app/Jobs/DispatchFaceScanJob.php create mode 100644 app/Models/Face.php create mode 100644 app/Models/FaceSuggestion.php create mode 100644 app/Models/Person.php create mode 100644 app/Observers/PhotoObserver.php create mode 100644 app/Policies/AiVisionPolicy.php create mode 100644 app/Services/Image/FacialRecognitionService.php create mode 100644 database/factories/FaceFactory.php create mode 100644 database/factories/PersonFactory.php create mode 100644 database/migrations/2026_03_21_000001_create_persons_table.php create mode 100644 database/migrations/2026_03_21_000002_create_faces_table.php create mode 100644 database/migrations/2026_03_21_000003_ai_vision_config.php create mode 100644 database/migrations/2026_03_21_000004_create_ai_vision_category.php create mode 100644 database/migrations/2026_03_21_000005_add_representative_face_id_to_persons.php create mode 100644 database/migrations/2026_03_21_000006_add_cluster_label_to_faces.php create mode 100644 docs/specs/2-how-to/configure-facial-recognition.md create mode 100644 docs/specs/2-how-to/docker-compose/docker-compose-ai-vision-only.yaml create mode 100644 lang/en/people.php create mode 100644 resources/js/components/gallery/PersonCard.vue create mode 100644 resources/js/components/gallery/photoModule/FaceOverlay.vue create mode 100644 resources/js/components/maintenance/MaintenanceBulkScanFaces.vue create mode 100644 resources/js/components/maintenance/MaintenanceDestroyDismissedFaces.vue create mode 100644 resources/js/components/maintenance/MaintenanceResetFaceScanStatus.vue create mode 100644 resources/js/components/maintenance/MaintenanceRunClustering.vue create mode 100644 resources/js/components/maintenance/MaintenanceSyncFaceEmbeddings.vue create mode 100644 resources/js/components/modals/FaceAssignmentModal.vue create mode 100644 resources/js/components/modals/MergePersonModal.vue create mode 100644 resources/js/components/modals/SelfieClaimModal.vue create mode 100644 resources/js/services/face-cluster-service.ts create mode 100644 resources/js/services/face-detection-service.ts create mode 100644 resources/js/services/people-service.ts create mode 100644 resources/js/views/face-recog/FaceClusters.vue create mode 100644 resources/js/views/face-recog/People.vue create mode 100644 resources/js/views/face-recog/PersonDetail.vue create mode 100644 tests/Feature_v2/AiVision/FaceAssignmentTest.php create mode 100644 tests/Feature_v2/AiVision/FaceClusterReviewTest.php create mode 100644 tests/Feature_v2/AiVision/FaceDetectionServiceUnavailableTest.php create mode 100644 tests/Feature_v2/AiVision/FaceDetectionTest.php create mode 100644 tests/Feature_v2/AiVision/FaceDismissTest.php create mode 100644 tests/Feature_v2/AiVision/MaintenanceResetStuckFacesTest.php create mode 100644 tests/Feature_v2/AiVision/PeopleControllerTest.php create mode 100644 tests/Feature_v2/AiVision/PersonClaimTest.php create mode 100644 tests/Feature_v2/AiVision/PersonPhotosTest.php create mode 100644 tests/Feature_v2/AiVision/ScanFacesCommandTest.php create mode 100644 tests/Feature_v2/AiVision/SelfieClaimTest.php create mode 100644 tests/Unit/Models/FaceTest.php create mode 100644 tests/Unit/Models/PersonTest.php diff --git a/.dockerignore b/.dockerignore index 89fe7008049..018f26f7bb1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -48,6 +48,8 @@ vite/* secrets/* # Local testing @ildyria public/uploads-bck/* +public/uploads/* +*.sql # Node node_modules/ @@ -56,8 +58,12 @@ npm-debug.log # Mapping for database and config used by docker compose lychee/* +# Python +ai-vision-service/* + # Laravel /storage/logs/* +/storage/tmp/* /storage/framework/cache/* /storage/framework/sessions/* /storage/framework/views/* diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8d5825f1f78..481a3a18415 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,9 +18,6 @@ updates: dependency-type: "production" development-dependencies: dependency-type: "development" - ignore: - - dependency-name: "typescript" - versions: [ ">=6.0.0" ] - package-ecosystem: composer directory: / diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml index 780426a6fcc..4f41d0cb013 100644 --- a/.github/workflows/CICD.yml +++ b/.github/workflows/CICD.yml @@ -11,11 +11,13 @@ on: - '**/*.md' - 'public/dist/*.js' - 'public/dist/**/*.js' + - 'ai-vision-service/**' pull_request: paths-ignore: - '**/*.md' - 'public/dist/*.js' - 'public/dist/**/*.js' + - 'ai-vision-service/**' # Allow manually triggering the workflow. workflow_dispatch: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 1d8a2052c05..79b62357093 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -25,3 +25,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + # No fix available yet + # Note that the model is directly baked into the inage + # So the risk is limited. + allow-ghsas: GHSA-hqmj-h5c6-369m diff --git a/.github/workflows/python_ai_vision_face_recognition.yml b/.github/workflows/python_ai_vision_face_recognition.yml new file mode 100644 index 00000000000..5d9e295ad69 --- /dev/null +++ b/.github/workflows/python_ai_vision_face_recognition.yml @@ -0,0 +1,154 @@ +name: Python AI Vision Service for face recognition + +on: + push: + branches: + - master + - assisted-vision + paths: + - 'ai-vision-service/face-recognition/**' + pull_request: + paths: + - 'ai-vision-service/face-recognition/**' + workflow_dispatch: + +# Declare default permissions as read only. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ !contains(github.ref, 'master') && !startsWith(github.ref, 'refs/tags/') }} + +defaults: + run: + working-directory: ai-vision-service/face-recognition + +jobs: + # --------------------------------------------------------------------------- + # Lint – formatting and style + # --------------------------------------------------------------------------- + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true + + - name: Install dev dependencies + run: uv sync --frozen + + - name: Check formatting + run: uv run ruff format --check app/ tests/ + + - name: Lint + run: uv run ruff check app/ tests/ + + # --------------------------------------------------------------------------- + # Type check + # --------------------------------------------------------------------------- + typecheck: + name: Type check (ty) + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true + + - name: Install dev dependencies + run: uv sync --frozen + + - name: Type check + run: uv run ty check app/ + + # --------------------------------------------------------------------------- + # Test matrix – Python 3.13 and 3.14 + # --------------------------------------------------------------------------- + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.13" + - "3.14" + + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Install dev dependencies + run: uv sync --frozen + + - name: Run tests + run: uv run pytest --cov=app --cov-report=xml -v + + - name: Upload coverage + if: matrix.python-version == '3.13' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + with: + files: ai-vision-service/face-recognition/coverage.xml + flags: ai-vision-service/face-recognition + continue-on-error: true + + # --------------------------------------------------------------------------- + # Docker build verification + # --------------------------------------------------------------------------- + docker-build: + name: Docker build + runs-on: ubuntu-latest + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0 + + - name: Build Docker image (no model bake in CI to save time) + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: ai-vision-service/face-recognition + push: false + load: true + tags: lychee-ai-vision:ci + # Override the model-bake step by targeting the builder stage + # to avoid downloading 300MB of model weights in CI. + target: builder + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 0089dc9ee36..b0ec9675541 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ clover.xml .NO_AUTO_COMPOSER_MIGRATE storage/bootstrap/cache/* storage/image-jobs/* +**/__pycache__/** +.coverage # used by Vite public/hot diff --git a/Makefile b/Makefile index 29671d52a63..113d68cf32c 100644 --- a/Makefile +++ b/Makefile @@ -166,6 +166,9 @@ class-leak: docker-build: docker build -t lychee-frankenphp . +docker-build-legacy: + docker build -t lychee-frankenphp -f Dockerfile-legacy . + docker-build-no-cache: docker build -t lychee-frankenphp . --no-cache diff --git a/ai-vision-service/face-recognition/.insightface/.gitignore b/ai-vision-service/face-recognition/.insightface/.gitignore new file mode 100644 index 00000000000..c96a04f008e --- /dev/null +++ b/ai-vision-service/face-recognition/.insightface/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ai-vision-service/face-recognition/Dockerfile b/ai-vision-service/face-recognition/Dockerfile new file mode 100644 index 00000000000..391dfe0bd84 --- /dev/null +++ b/ai-vision-service/face-recognition/Dockerfile @@ -0,0 +1,54 @@ +# Multi-stage build: keep the runtime image lean. + +# --------------------------------------------------------------------------- +# Stage 1 – builder: install dependencies and bake the model weights. +# --------------------------------------------------------------------------- +FROM python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d AS builder + +# Install uv from the official image. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Build tools required to compile insightface's Cython extension (mesh_core_cython). +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ \ + libgl1 \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install dependencies only (no source code) so layer cache is reused when +# only application code changes. +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-dev + +# Bake buffalo_l model weights into the image at build time (~300 MB download). +# The resulting image is ~1 GB larger but starts instantly and works in +# airgapped environments. Model updates require an image rebuild. +RUN uv run python -c \ + "from insightface.app import FaceAnalysis; \ + a = FaceAnalysis(name='buffalo_l', root='/root/.insightface', providers=['CPUExecutionProvider']); \ + a.prepare(ctx_id=-1); \ + print('buffalo_l model downloaded.')" + +# --------------------------------------------------------------------------- +# Stage 2 – runtime: copy only what's needed to run. +# --------------------------------------------------------------------------- +FROM python:3.13-slim@sha256:739e7213785e88c0f702dcdc12c0973afcbd606dbf021a589cab77d6b00b579d AS runtime + +WORKDIR /app + +# Copy the pre-built virtualenv and baked model weights from the builder stage. +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /root/.insightface /root/.insightface + +# Copy application source. +COPY app/ ./app/ + +ENV PATH="/app/.venv/bin:$PATH" + +EXPOSE 8000 + +# Use a shell-form CMD so that the ${VISION_FACE_WORKERS:-1} variable is +# expanded at container startup, not at image build time. +CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${VISION_FACE_WORKERS:-1}"] diff --git a/ai-vision-service/face-recognition/README.md b/ai-vision-service/face-recognition/README.md new file mode 100644 index 00000000000..489b414fdd1 --- /dev/null +++ b/ai-vision-service/face-recognition/README.md @@ -0,0 +1,146 @@ +# Lychee AI Vision Service + +Facial recognition microservice for [Lychee](https://github.com/LycheeOrg/Lychee). + +Detects faces in photos, stores embeddings, and supports selfie-based person +claiming via a REST API consumed by the Lychee PHP backend. + +## Tech stack + +| Concern | Library | +|---------|---------| +| Web framework | FastAPI + Uvicorn | +| Face detection & recognition | InsightFace (`buffalo_l`) + ONNX Runtime | +| Face crop generation | Pillow | +| Embedding clustering | scikit-learn (DBSCAN) | +| Embedding storage | SQLite + sqlite-vec (default) / PostgreSQL + pgvector | +| HTTP client (callbacks) | httpx | +| Config | Pydantic BaseSettings | + +## Directory layout + +``` +ai-vision-service/ +├── app/ +│ ├── __init__.py +│ ├── config.py # AppSettings (Pydantic BaseSettings) +│ ├── main.py # FastAPI app factory & lifespan handler +│ ├── api/ +│ │ ├── __init__.py +│ │ ├── dependencies.py # API key auth dependency +│ │ ├── routes.py # /detect, /match, /health +│ │ └── schemas.py # Pydantic request/response models +│ ├── detection/ +│ │ ├── __init__.py +│ │ ├── detector.py # InsightFace wrapper +│ │ └── cropper.py # 150×150 JPEG crop generator +│ ├── embeddings/ +│ │ ├── __init__.py +│ │ ├── store.py # Abstract EmbeddingStore protocol +│ │ ├── sqlite_store.py # SQLite + sqlite-vec implementation +│ │ └── pgvector_store.py # PostgreSQL + pgvector implementation +│ ├── clustering/ +│ │ ├── __init__.py +│ │ └── clusterer.py # DBSCAN clustering +│ └── matching/ +│ ├── __init__.py +│ └── matcher.py # Selfie similarity matching +├── tests/ +│ └── __init__.py +├── Dockerfile +├── pyproject.toml +└── README.md +``` + +## Environment variables + +All variables are prefixed `VISION_FACE_`. + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `VISION_FACE_LYCHEE_API_URL` | Yes | — | Lychee base URL for callbacks | +| `VISION_FACE_API_KEY` | Yes | — | Shared API key: validated on inbound requests from Lychee, and sent on outbound callbacks to Lychee | +| `VISION_FACE_VERIFY_SSL` | No | `true` | Verify SSL certificates when making callbacks to Lychee. Set to `false` for dev environments with self-signed certificates | +| `VISION_FACE_MODEL_NAME` | No | `buffalo_l` | InsightFace model pack | +| `VISION_FACE_DETECTION_THRESHOLD` | No | `0.5` | Confidence filter for detected faces | +| `VISION_FACE_MATCH_THRESHOLD` | No | `0.5` | Cosine-similarity cutoff for selfie matching | +| `VISION_FACE_RESCAN_IOU_THRESHOLD` | No | `0.5` | IoU threshold for bounding-box matching on re-scan | +| `VISION_FACE_MAX_FACES_PER_PHOTO` | No | `10` | Maximum faces included in a callback payload | +| `VISION_FACE_THREAD_POOL_SIZE` | No | `1` | Inference thread-pool size | +| `VISION_FACE_STORAGE_BACKEND` | No | `sqlite` | `sqlite` or `pgvector` | +| `VISION_FACE_STORAGE_PATH` | No | `/data/embeddings` | SQLite DB directory | +| `VISION_FACE_PG_HOST` | No* | `localhost` | PostgreSQL host (*required with pgvector) | +| `VISION_FACE_PG_PORT` | No | `5432` | PostgreSQL port | +| `VISION_FACE_PG_DATABASE` | No* | `ai_vision` | PostgreSQL database (*required with pgvector) | +| `VISION_FACE_PG_USER` | No* | `ai_vision` | PostgreSQL user (*required with pgvector) | +| `VISION_FACE_PG_PASSWORD` | No* | `` | PostgreSQL password (*required with pgvector) | +| `VISION_FACE_PHOTOS_PATH` | No | `/data/photos` | Shared volume mount for photo files | +| `VISION_FACE_WORKERS` | No | `1` | Number of Uvicorn worker processes | +| `VISION_FACE_LOG_LEVEL` | No | `info` | Log level | + +## Development + +### Setup + +```bash +# Install uv (https://docs.astral.sh/uv/getting-started/installation/) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install all dependencies (including dev) +uv sync + +# Configure .env file (create or edit .env in this directory) +# Minimum required variables: +# VISION_FACE_LYCHEE_API_URL=https://lychee.test +# VISION_FACE_API_KEY=changeme +# VISION_FACE_VERIFY_SSL=false +# VISION_FACE_PHOTOS_PATH=../../public/uploads +``` + +### Running locally + +```bash +# Using uv run (recommended) +uv run python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +The service will be available at http://localhost:8000 +- API docs: http://localhost:8000/docs +- Health check: http://localhost:8000/health + +### Linting and testing + +```bash +# Lint and format +uv run ruff format +uv run ruff check --fix + +# Type check +uv run ty check + +# Run tests +uv run pytest + +# Run tests with coverage +uv run pytest --cov=app --cov-report=html +``` + +## Docker + +```bash +# Build (bakes buffalo_l model into the image – ~1 GB download on first build) +docker build -t lychee-ai-vision . + +# Run +docker run --rm \ + -e VISION_FACE_LYCHEE_API_URL=http://lychee \ + -e VISION_FACE_API_KEY=changeme \ + -v /path/to/photos:/data/photos:ro \ + -v ai-vision-embeddings:/data/embeddings \ + -p 8000:8000 \ + lychee-ai-vision +``` + +--- + +*Last updated: 2026-03-21* diff --git a/ai-vision-service/face-recognition/app/__init__.py b/ai-vision-service/face-recognition/app/__init__.py new file mode 100644 index 00000000000..6f6a628fe93 --- /dev/null +++ b/ai-vision-service/face-recognition/app/__init__.py @@ -0,0 +1 @@ +"""Lychee AI Vision Service.""" diff --git a/ai-vision-service/face-recognition/app/api/__init__.py b/ai-vision-service/face-recognition/app/api/__init__.py new file mode 100644 index 00000000000..0a5af4779e5 --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI routes, dependencies, and schemas.""" diff --git a/ai-vision-service/face-recognition/app/api/dependencies.py b/ai-vision-service/face-recognition/app/api/dependencies.py new file mode 100644 index 00000000000..a89ee2f8138 --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/dependencies.py @@ -0,0 +1,42 @@ +"""FastAPI dependency providers. + +Centralises per-request dependency resolution. All functions follow +FastAPI's ``Depends()`` contract so they can be overridden in tests via +``app.dependency_overrides``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import Depends, Header, HTTPException, Request + +from app.config import AppSettings, get_settings + +if TYPE_CHECKING: + from app.detection.detector import FaceDetector + from app.embeddings.store import EmbeddingStore + + +async def require_api_key( + x_api_key: str = Header(..., alias="X-API-Key"), + settings: AppSettings = Depends(get_settings), +) -> None: + """FastAPI dependency that validates the ``X-API-Key`` request header. + + Raises: + HTTPException(401): If the header is missing or does not match + ``VISION_FACE_API_KEY``. + """ + if x_api_key != settings.api_key: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +def get_detector(request: Request) -> FaceDetector: + """Return the :class:`FaceDetector` stored in ``app.state``.""" + return request.app.state.detector + + +def get_store(request: Request) -> EmbeddingStore: + """Return the :class:`EmbeddingStore` stored in ``app.state``.""" + return request.app.state.store diff --git a/ai-vision-service/face-recognition/app/api/routes.py b/ai-vision-service/face-recognition/app/api/routes.py new file mode 100644 index 00000000000..39519e915d7 --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/routes.py @@ -0,0 +1,553 @@ +"""FastAPI route handlers. + +Endpoints: + POST /detect - Accept a face-detection job; run async, callback to Lychee. + POST /match - Accept a selfie; return top-N similar stored faces. + GET /health - Return service health and embedding count. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from pathlib import Path +from typing import TYPE_CHECKING + +import httpx +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile +from fastapi.responses import RedirectResponse + +from app.api.dependencies import get_detector, get_store, require_api_key +from app.api.schemas import ( + ClusterCallbackPayload, + ClusterFaceResult, + ClusterSuggestion, + DeleteEmbeddingsRequest, + DeleteEmbeddingsResponse, + DetectCallbackPayload, + DetectCallbackResponse, + DetectRequest, + EmbeddingExportItem, + EmbeddingExportResponse, + ErrorCallbackPayload, + FaceResult, + HealthResponse, + MatchResponse, + MatchResult, + SuggestionResult, +) +from app.clustering.clusterer import FaceClusterer +from app.config import AppSettings, get_settings +from app.detection.cropper import generate_crop + +if TYPE_CHECKING: + from concurrent.futures import Executor + + from app.detection.detector import DetectedFace, FaceDetector + from app.embeddings.store import EmbeddingStore + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# --------------------------------------------------------------------------- +# GET / - Redirect to /health +# --------------------------------------------------------------------------- + + +@router.get("/") +async def root() -> RedirectResponse: + """Redirect root to /health endpoint.""" + return RedirectResponse(url="/health") + + +# --------------------------------------------------------------------------- +# POST /detect +# --------------------------------------------------------------------------- + + +@router.post("/detect", status_code=202) +async def detect( + body: DetectRequest, + background_tasks: BackgroundTasks, + request: Request, + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> None: + """Accept a face-detection job. + + Validates the photo path (path-traversal protection), then immediately + returns **202 Accepted** and schedules detection as a background task. + Results are POSTed back to Lychee's results endpoint once detection + completes. + """ + resolved = Path(settings.photos_path.removesuffix("/") + "/" + body.photo_path.removeprefix("/")).resolve() + photos_root = Path(settings.photos_path).resolve() + + if not str(resolved).startswith(str(photos_root) + "/") and resolved != photos_root: + raise HTTPException(status_code=400, detail=f"photo_path {resolved} is outside the allowed directory") + + if not resolved.is_file(): + raise HTTPException(status_code=400, detail="photo_path does not exist or is not a file") + + detector: FaceDetector = get_detector(request) + store: EmbeddingStore = get_store(request) + executor: Executor = request.app.state.executor + + background_tasks.add_task( + _run_detection_job, + body.photo_id, + resolved, + detector, + store, + executor, + settings, + ) + + +# --------------------------------------------------------------------------- +# POST /match +# --------------------------------------------------------------------------- + + +@router.post("/match") +async def match( + file: UploadFile, + request: Request, + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> MatchResponse: + """Match a selfie against stored face embeddings. + + Accepts a multipart image upload, detects the face, embeds it, and returns + the closest matches from the embedding store above the configured threshold. + + Returns **422** if no face is detected in the selfie. + """ + image_bytes = await file.read() + + detector: FaceDetector = get_detector(request) + store: EmbeddingStore = get_store(request) + executor: Executor = request.app.state.executor + + logger.info("Processing selfie match request (%d bytes)", len(image_bytes)) + + loop = asyncio.get_running_loop() + raw_faces: list[DetectedFace] = await loop.run_in_executor(executor, detector.detect_bytes, image_bytes) + + if not raw_faces: + logger.warning("No face detected in uploaded selfie image") + raise HTTPException(status_code=422, detail="No face detected in the uploaded image") + + best = raw_faces[0] # highest confidence (sorted descending) + matches = store.similarity_search(best.embedding, settings.match_threshold, limit=10) + + logger.info( + "Selfie match found %d match(es) above threshold %.2f (detected face confidence: %.3f)", + len(matches), + settings.match_threshold, + best.confidence, + ) + + return MatchResponse(matches=[MatchResult(lychee_face_id=face_id, confidence=conf) for face_id, conf in matches]) + + +# --------------------------------------------------------------------------- +# DELETE /embeddings +# --------------------------------------------------------------------------- + + +@router.delete("/embeddings") +async def delete_embeddings( + body: DeleteEmbeddingsRequest, + request: Request, + _: None = Depends(require_api_key), +) -> DeleteEmbeddingsResponse: + """Delete face embeddings by their Lychee Face IDs. + + Called by Lychee when dismissed faces are permanently removed or when + a photo is deleted. + """ + store: EmbeddingStore = get_store(request) + deleted = store.delete_many(body.face_ids) + return DeleteEmbeddingsResponse(deleted=deleted) + + +@router.get("/embeddings/export") +async def export_embeddings( + request: Request, + _: None = Depends(require_api_key), +) -> EmbeddingExportResponse: + """Export all face embeddings with metadata for synchronization. + + Called by Lychee maintenance to re-sync face data after callback failures + or to verify database consistency. + """ + store: EmbeddingStore = get_store(request) + all_data = store.get_all_with_metadata() + + items = [ + EmbeddingExportItem( + lychee_face_id=row["lychee_face_id"], + photo_id=row["photo_id"], + laplacian_variance=row["laplacian_variance"], + crop_path=row["crop_path"], + ) + for row in all_data + ] + + return EmbeddingExportResponse(count=len(items), embeddings=items) + + +# --------------------------------------------------------------------------- +# POST /cluster +# --------------------------------------------------------------------------- + + +@router.post("/cluster", status_code=202) +async def cluster( + background_tasks: BackgroundTasks, + request: Request, + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> None: + """Run DBSCAN clustering over all stored face embeddings. + + Immediately returns **202 Accepted** and schedules clustering as a background + task. Results are POSTed back to Lychee's clustering results endpoint once + clustering completes. + """ + store: EmbeddingStore = get_store(request) + executor: Executor = request.app.state.executor + + background_tasks.add_task( + _run_clustering_job, + store, + executor, + settings, + ) + + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + + +@router.get("/health") +async def health(request: Request) -> HealthResponse: + """Return service health, model-loaded status, and embedding count. + + This endpoint is intentionally unauthenticated so that load-balancers and + Docker health checks can probe it without an API key. + """ + detector: FaceDetector = request.app.state.detector + store: EmbeddingStore = request.app.state.store + + return HealthResponse( + status="ok" if detector.is_loaded else "degraded", + model_loaded=detector.is_loaded, + embedding_count=store.count(), + ) + + +# --------------------------------------------------------------------------- +# Background detection job +# --------------------------------------------------------------------------- + + +async def _run_detection_job( + photo_id: str, + image_path: Path, + detector: FaceDetector, + store: EmbeddingStore, + executor: Executor, + settings: AppSettings, +) -> None: + """Detect faces, build the callback payload, and notify Lychee. + + Runs entirely as an async background task after the ``/detect`` route has + returned 202. All CPU-bound work is offloaded to ``executor`` via + ``run_in_executor`` so the event loop remains responsive. + """ + logger.info("Starting detection job for photo_id=%s, path=%s", photo_id, image_path) + try: + loop = asyncio.get_running_loop() + + # --- 1. Detect faces (CPU-bound, runs in thread pool) --- + raw_faces: list[DetectedFace] = await loop.run_in_executor(executor, detector.detect, image_path) + + if len(raw_faces) > settings.max_faces_per_photo: + logger.info( + "Limiting faces from %d to %d (max_faces_per_photo setting)", + len(raw_faces), + settings.max_faces_per_photo, + ) + raw_faces = raw_faces[: settings.max_faces_per_photo] + + if not raw_faces: + logger.info("No faces detected in photo_id=%s, sending empty results", photo_id) + + # --- 2. For each face: generate crop + search suggestions --- + face_data: list[tuple[str, list[float], FaceResult]] = [] + + for raw_face in raw_faces: + emp_id = str(uuid.uuid4()) + + crop_b64: str = await loop.run_in_executor( + executor, + generate_crop, + image_path, + raw_face.x, + raw_face.y, + raw_face.width, + raw_face.height, + ) + + suggestions = store.similarity_search(raw_face.embedding, settings.match_threshold, limit=10) + + if suggestions: + logger.debug( + "Found %d suggestion(s) for face with confidence=%.3f", + len(suggestions), + raw_face.confidence, + ) + + result = FaceResult( + x=raw_face.x, + y=raw_face.y, + width=raw_face.width, + height=raw_face.height, + confidence=raw_face.confidence, + embedding_id=emp_id, + crop=crop_b64, + laplacian_variance=raw_face.laplacian_variance, + suggestions=[SuggestionResult(lychee_face_id=fid, confidence=conf) for fid, conf in suggestions], + ) + face_data.append((emp_id, raw_face.embedding, result)) + + # --- 3. POST success callback to Lychee --- + payload = DetectCallbackPayload( + photo_id=photo_id, + faces=[fd[2] for fd in face_data], + ) + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/results" + logger.debug("Sending detection results to Lychee for photo_id=%s (%d face(s))", photo_id, len(face_data)) + logger.debug("Detection callback payload: %s", payload.model_dump()) + + + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + response = await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=30.0, + ) + response.raise_for_status() + callback_resp = DetectCallbackResponse.model_validate(response.json()) + + logger.info( + "Successfully sent detection results to Lychee for photo_id=%s (%d face(s))", + photo_id, + len(face_data), + ) + + # --- 4. Persist embeddings + crops now that we have stable lychee_face_ids --- + id_to_data: dict[str, tuple[list[float], FaceResult]] = {eid: (vec, res) for eid, vec, res in face_data} + crop_dir = Path("data/faces") + crop_dir.mkdir(parents=True, exist_ok=True) + + for mapping in callback_resp.faces: + data = id_to_data.get(mapping.embedding_id) + if data is not None: + vec, face_result = data + lychee_face_id = mapping.lychee_face_id + + # Save face crop to disk + crop_path = f"faces/{lychee_face_id}.jpg" + crop_file = crop_dir / f"{lychee_face_id}.jpg" + import base64 + crop_bytes = base64.b64decode(face_result.crop) + crop_file.write_bytes(crop_bytes) + + # Persist embedding with metadata + store.add( + lychee_face_id=lychee_face_id, + embedding=vec, + photo_id=photo_id, + laplacian_variance=face_result.laplacian_variance, + crop_path=crop_path, + ) + + except Exception: + logger.exception("Detection job failed for photo_id=%s; sending error callback", photo_id) + await _send_error_callback(photo_id, "internal_error", "Detection pipeline failed", settings) + + +async def _send_error_callback(photo_id: str, error_code: str, message: str, settings: AppSettings) -> None: + """Best-effort POST of an error callback to Lychee.""" + payload = ErrorCallbackPayload(photo_id=photo_id, error_code=error_code, message=message) + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/results" + try: + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=10.0, + ) + except Exception: + logger.exception("Failed to send error callback for photo_id=%s", photo_id) + + +# --------------------------------------------------------------------------- +# Background clustering job +# --------------------------------------------------------------------------- + + +async def _run_clustering_job( + store: EmbeddingStore, + executor: Executor, + settings: AppSettings, +) -> None: + """Run DBSCAN clustering and notify Lychee with results. + + Runs entirely as an async background task after the ``/cluster`` route has + returned 202. CPU-bound clustering work is offloaded to ``executor`` via + ``run_in_executor`` so the event loop remains responsive. + """ + try: + # --- 1. Fetch all embeddings from store --- + all_embeddings = store.get_all() + + if not all_embeddings: + # Send success callback with empty results + payload = ClusterCallbackPayload(labels=[]) + await _send_cluster_callback(payload, settings) + return + + # --- 2. Run DBSCAN clustering (CPU-bound, runs in thread pool) --- + loop = asyncio.get_running_loop() + clusterer = FaceClusterer(eps=settings.cluster_eps) + results: list[tuple[str, int]] = await loop.run_in_executor( + executor, + clusterer.cluster, + all_embeddings, + ) + + # --- 3. Build cluster label assignments --- + labels = [ClusterFaceResult(face_id=fid, cluster_label=label) for fid, label in results] + + # --- 4. Generate cross-cluster suggestions --- + # Only include face_ids that exist in cluster_results to pass PHP's exists:faces,id validation + valid_face_ids = {fid for fid, _ in results} + suggestions = _generate_cross_cluster_suggestions( + results, + all_embeddings, + store, + valid_face_ids, + settings.match_threshold, + ) + + payload = ClusterCallbackPayload(labels=labels, suggestions=suggestions) + + # --- 5. POST success callback to Lychee --- + await _send_cluster_callback(payload, settings) + + except Exception: + logger.exception("Clustering job failed; sending empty results to Lychee") + # PHP endpoint doesn't handle error payloads, so send empty results + try: + empty_payload = ClusterCallbackPayload(labels=[]) + await _send_cluster_callback(empty_payload, settings) + except Exception: + logger.exception("Failed to send fallback empty clustering results") + + +async def _send_cluster_callback(payload: ClusterCallbackPayload, settings: AppSettings) -> None: + """POST clustering results to Lychee.""" + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/cluster-results" + try: + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + response = await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=30.0, + ) + response.raise_for_status() + except Exception: + logger.exception("Failed to send clustering callback") + raise + + +def _generate_cross_cluster_suggestions( + cluster_results: list[tuple[str, int]], + all_embeddings: list[tuple[str, list[float]]], + store: EmbeddingStore, + valid_face_ids: set[str], + threshold: float, + max_per_face: int = 3, +) -> list[ClusterSuggestion]: + """Generate cross-cluster face suggestions for UI review. + + For each clustered face, find similar faces from different clusters + that are above the similarity threshold. This helps identify potential + mis-clusterings and allows manual review. + + Args: + cluster_results: List of (face_id, cluster_label) tuples + all_embeddings: List of (face_id, embedding) tuples + store: Embedding store for similarity search + valid_face_ids: Set of face IDs that exist in Lychee's database + threshold: Minimum similarity threshold + max_per_face: Maximum suggestions per face + """ + suggestions = [] + face_to_cluster = {fid: label for fid, label in cluster_results} + embedding_map = {fid: emb for fid, emb in all_embeddings} + + for face_id, cluster_label in cluster_results: + # Skip noise points (cluster_label == -1) + if cluster_label == -1: + continue + + embedding = embedding_map.get(face_id) + if embedding is None: + continue + + # Find similar faces from the embedding store + matches = store.similarity_search(embedding, threshold, limit=max_per_face + 10) + + # Filter to only faces from different clusters that exist in Lychee's database + for suggested_face_id, confidence in matches: + # Skip if suggested face doesn't exist in Lychee's database + if suggested_face_id not in valid_face_ids: + continue + + suggested_cluster = face_to_cluster.get(suggested_face_id) + if suggested_cluster is not None and suggested_cluster != cluster_label and suggested_cluster != -1: + suggestions.append( + ClusterSuggestion( + face_id=face_id, + suggested_face_id=suggested_face_id, + confidence=float(confidence), # Ensure it's a Python float, not numpy + ) + ) + if len([s for s in suggestions if s.face_id == face_id]) >= max_per_face: + break + + return suggestions diff --git a/ai-vision-service/face-recognition/app/api/schemas.py b/ai-vision-service/face-recognition/app/api/schemas.py new file mode 100644 index 00000000000..1e3f4188f83 --- /dev/null +++ b/ai-vision-service/face-recognition/app/api/schemas.py @@ -0,0 +1,267 @@ +"""Pydantic request/response schemas for the AI Vision Service API. + +These models define the contract between the Python service and Lychee (PHP). +All fields are fully type-annotated; no ``Any`` types are used. +""" + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# /detect - Lychee -> Python (request) & Python -> Lychee (callback) +# --------------------------------------------------------------------------- + + +class DetectRequest(BaseModel): + """Body sent by Lychee when dispatching a face-detection job. + + ``callback_url`` is intentionally absent: the service reads + ``VISION_FACE_LYCHEE_API_URL`` from env and posts results there directly. + """ + + photo_id: str + """Lychee-internal photo identifier (string PK).""" + + photo_path: str + """Absolute filesystem path on the shared Docker volume. + + The service validates that this path starts with ``VISION_FACE_PHOTOS_PATH`` + before opening the file (path-traversal protection). + """ + + +class SuggestionResult(BaseModel): + """A single similar face returned as a suggestion.""" + + lychee_face_id: str + """The Lychee ``Face.id`` of the suggested similar face. + + Stored as ``face_suggestions.suggested_face_id`` in the PHP layer; + Lychee JOINs to resolve the linked person at read time. + """ + + confidence: float = Field(ge=0.0, le=1.0) + """Cosine-similarity score (0.0-1.0) between the new embedding and this suggestion.""" + + +class FaceResult(BaseModel): + """One detected face included in the callback payload.""" + + x: float = Field(ge=0.0, le=1.0) + """Left edge of the bounding box as a fraction of image width.""" + + y: float = Field(ge=0.0, le=1.0) + """Top edge of the bounding box as a fraction of image height.""" + + width: float = Field(ge=0.0, le=1.0) + """Bounding-box width as a fraction of image width.""" + + height: float = Field(ge=0.0, le=1.0) + """Bounding-box height as a fraction of image height.""" + + confidence: float = Field(ge=0.0, le=1.0) + """Detection confidence score from the face detector.""" + + embedding_id: str + """Transient identifier generated by the service for this embedding. + + Echoed back in ``DetectCallbackResponse.faces`` so the service can update + its embedding store with the ``lychee_face_id`` assigned by Lychee. + This value is *not* persisted on the Lychee ``Face`` model. + """ + + crop: str + """Base64-encoded 150x150 JPEG face crop. + + Lychee stores it at ``uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg`` + where ``token`` is a random high-entropy string generated by Lychee. + Only the top ``max_faces_per_photo`` faces (by confidence) are included. + """ + + laplacian_variance: float + """Laplacian variance sharpness score for the face crop. + + Higher values indicate sharper images. Lychee stores this value to allow + users to tune quality filtering thresholds without re-scanning. + """ + + suggestions: list[SuggestionResult] = [] + """Pre-computed similar faces from the embedding store (may be empty).""" + + +class DetectCallbackPayload(BaseModel): + """Payload POSTed by the service to Lychee's results endpoint on success.""" + + photo_id: str + status: str = "success" + faces: list[FaceResult] + + +class ErrorCallbackPayload(BaseModel): + """Payload POSTed by the service to Lychee's results endpoint on failure.""" + + photo_id: str + status: str = "error" + error_code: str + """Machine-readable error code (e.g. ``"corrupt_file"``, ``"no_faces"``, ``"oom"``).""" + message: str + """Human-readable description of the failure.""" + + +class FaceMapping(BaseModel): + """Mapping returned by Lychee after processing a successful callback. + + The service uses these to update its embedding store with the stable + Lychee-assigned ``lychee_face_id`` for each embedding. + """ + + embedding_id: str + """Echoed from ``FaceResult.embedding_id``.""" + + lychee_face_id: str + """Stable Lychee ``Face.id`` assigned to this embedding.""" + + +class DetectCallbackResponse(BaseModel): + """200 response body returned by Lychee after a successful callback.""" + + faces: list[FaceMapping] + + +# --------------------------------------------------------------------------- +# /match - Lychee -> Python (multipart file) & Python -> Lychee (response) +# --------------------------------------------------------------------------- + + +class MatchResult(BaseModel): + """A single candidate face returned from a selfie-match query.""" + + lychee_face_id: str + """Stable Lychee ``Face.id``; resolved to ``person_id`` by Lychee.""" + + confidence: float = Field(ge=0.0, le=1.0) + """Cosine-similarity score between the selfie embedding and this face.""" + + +class MatchResponse(BaseModel): + """Response body for ``POST /match``.""" + + matches: list[MatchResult] + """Top-N matches above ``VISION_FACE_MATCH_THRESHOLD``, ordered by descending confidence.""" + + +# --------------------------------------------------------------------------- +# DELETE /embeddings - Lychee -> Python +# --------------------------------------------------------------------------- + + +class DeleteEmbeddingsRequest(BaseModel): + """Body sent by Lychee to delete face embeddings from the store.""" + + face_ids: list[str] = Field(min_length=1) + """List of Lychee ``Face.id`` values whose embeddings should be removed.""" + + +class DeleteEmbeddingsResponse(BaseModel): + """Response body for ``DELETE /embeddings``.""" + + deleted: int + """Number of embeddings actually removed (IDs not found are silently skipped).""" + + +# --------------------------------------------------------------------------- +# POST /cluster - Lychee -> Python +# --------------------------------------------------------------------------- + + +class ClusterFaceResult(BaseModel): + """One face's cluster assignment.""" + + face_id: str + """Lychee ``Face.id``.""" + + cluster_label: int + """DBSCAN cluster label. ``-1`` = noise / unassigned.""" + + +class ClusterSuggestion(BaseModel): + """Cross-cluster face suggestion for UI review.""" + + face_id: str + """The face that should show this suggestion.""" + + suggested_face_id: str + """The suggested similar face from a different cluster.""" + + confidence: float = Field(ge=0.0, le=1.0) + """Cosine-similarity score between the two faces.""" + + +class ClusterResponse(BaseModel): + """Response body for ``POST /cluster``.""" + + total_faces: int + """Total number of embeddings that were clustered.""" + + num_clusters: int + """Number of distinct clusters found (excluding noise).""" + + labels: list[ClusterFaceResult] + """Per-face cluster label assignments.""" + + +class ClusterCallbackPayload(BaseModel): + """Payload POSTed by the service to Lychee's clustering results endpoint on success.""" + + model_config = {"validate_assignment": True} + + labels: list[ClusterFaceResult] + """Per-face cluster label assignments.""" + + suggestions: list[ClusterSuggestion] = [] + """Cross-cluster face suggestions (optional).""" + + +# --------------------------------------------------------------------------- +# /health +# --------------------------------------------------------------------------- + + +class HealthResponse(BaseModel): + """Response body for ``GET /health``.""" + + status: str + """``"ok"`` when fully operational; ``"degraded"`` if the model is not loaded.""" + + model_loaded: bool + """Whether the InsightFace model pack has been successfully initialised.""" + + embedding_count: int + """Total number of face embeddings currently stored.""" + + +# --------------------------------------------------------------------------- +# GET /embeddings/export - Python -> Lychee (for sync) +# --------------------------------------------------------------------------- + + +class EmbeddingExportItem(BaseModel): + """One face embedding metadata record for syncing.""" + + lychee_face_id: str + """Stable Lychee ``Face.id``.""" + + photo_id: str + """Lychee photo ID.""" + + laplacian_variance: float + """Sharpness score.""" + + crop_path: str + """Relative path to stored face crop.""" + + +class EmbeddingExportResponse(BaseModel): + """Response body for ``GET /embeddings/export``.""" + + embeddings: list[EmbeddingExportItem] + """All stored face embeddings with metadata.""" diff --git a/ai-vision-service/face-recognition/app/clustering/__init__.py b/ai-vision-service/face-recognition/app/clustering/__init__.py new file mode 100644 index 00000000000..089731a0f84 --- /dev/null +++ b/ai-vision-service/face-recognition/app/clustering/__init__.py @@ -0,0 +1 @@ +"""Face embedding clustering (DBSCAN).""" diff --git a/ai-vision-service/face-recognition/app/clustering/clusterer.py b/ai-vision-service/face-recognition/app/clustering/clusterer.py new file mode 100644 index 00000000000..82287a4d8df --- /dev/null +++ b/ai-vision-service/face-recognition/app/clustering/clusterer.py @@ -0,0 +1,80 @@ +"""Face embedding clustering using scikit-learn DBSCAN. + +Groups stored face embeddings into clusters automatically, without requiring +the number of clusters to be specified in advance. Each cluster corresponds +to a likely distinct identity; noise points (label ``-1``) are unassigned. +""" + +from __future__ import annotations + +import logging + +import numpy as np +from sklearn.cluster import DBSCAN + +logger = logging.getLogger(__name__) + + +class FaceClusterer: + """Clusters face embeddings with DBSCAN. + + DBSCAN (Density-Based Spatial Clustering of Applications with Noise) is + well-suited for face grouping because: + - The number of clusters does not need to be specified up front. + - Noise / outlier faces are marked with label ``-1`` rather than forced + into a cluster. + - It works on cosine distance natively when ``metric="cosine"``. + + Args: + eps: Maximum cosine *distance* (1 - similarity) between two samples + for them to be considered neighbours. Lower values mean tighter + clusters. + min_samples: Minimum number of samples in a neighbourhood to form a + core point. Set to ``1`` so that even a single unique face gets + its own cluster (rather than being labelled noise). + """ + + def __init__(self, eps: float = 0.4, min_samples: int = 1) -> None: + self._eps = eps + self._min_samples = min_samples + + def cluster(self, face_embeddings: list[tuple[str, list[float]]]) -> list[tuple[str, int]]: + """Cluster the supplied embeddings. + + Args: + face_embeddings: List of ``(lychee_face_id, embedding)`` pairs. + Each embedding is a 512-dimensional float list. + + Returns: + List of ``(lychee_face_id, cluster_label)`` pairs in the same + order as the input. ``cluster_label == -1`` means the face was + classified as noise. + + Returns an empty list when ``face_embeddings`` is empty. + """ + if not face_embeddings: + return [] + + ids = [fid for fid, _ in face_embeddings] + vectors = np.array([emb for _, emb in face_embeddings], dtype=np.float32) + + # Normalise vectors to unit length so that Euclidean distance equals + # sqrt(2 * (1 - cosine_similarity)). DBSCAN's cosine metric directly + # uses (1 - cosine_similarity) as the distance measure. + norms: np.ndarray = np.linalg.norm(vectors, axis=1, keepdims=True) + # Avoid division by zero for zero-norm vectors + norms = np.where(norms == 0, 1.0, norms) + vectors = vectors / norms + + logger.info( + "Start clustering %d face embeddings with DBSCAN (eps=%.2f, min_samples=%d)", + len(face_embeddings), + self._eps, + self._min_samples, + ) + db = DBSCAN(eps=self._eps, min_samples=self._min_samples, metric="cosine") + labels: list[int] = db.fit_predict(vectors).tolist() + logger.info("Done clustering %d face embeddings with DBSCAN", len(face_embeddings)) + logger.info("Cluster label distribution: %s", dict(zip(*np.unique(labels, return_counts=True), strict=False))) + + return list(zip(ids, labels, strict=True)) diff --git a/ai-vision-service/face-recognition/app/config.py b/ai-vision-service/face-recognition/app/config.py new file mode 100644 index 00000000000..9991f7d960e --- /dev/null +++ b/ai-vision-service/face-recognition/app/config.py @@ -0,0 +1,128 @@ +"""Application configuration via Pydantic BaseSettings. + +All settings are loaded from environment variables prefixed with ``VISION_FACE_``. +Example: the ``api_key`` field maps to the ``VISION_FACE_API_KEY`` env var. +""" + +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AppSettings(BaseSettings): + """Runtime configuration for the AI Vision Service. + + All values are read from environment variables prefixed ``VISION_FACE_``. + """ + + # --- Required --- + lychee_api_url: str + """Lychee instance base URL for callbacks (e.g. ``http://lychee``). No trailing slash.""" + + api_key: str + """Shared API key used in both directions: validated on *inbound* requests from Lychee + (``X-API-Key`` header) and sent as ``X-API-Key`` on *outbound* callbacks to Lychee. + Must match ``AI_VISION_FACE_API_KEY`` in the Lychee ``.env``.""" + + verify_ssl: bool = True + """Whether to verify SSL certificates when making callbacks to Lychee. + Set to ``False`` for development environments with self-signed certificates. + **WARNING:** Disabling SSL verification in production is a security risk.""" + + # --- Model --- + model_name: str = "buffalo_l" + """InsightFace model pack name. ``buffalo_l`` = large/high-accuracy; ``buffalo_s`` = faster alternative.""" + + # --- Detection thresholds --- + detection_threshold: float = 0.5 + """Bounding-box confidence filter — faces below this score are excluded from the callback payload.""" + + match_threshold: float = 0.5 + """Cosine-similarity cutoff for selfie match results and suggestion candidates.""" + + rescan_iou_threshold: float = 0.5 + """IoU threshold for bounding-box matching on re-scan (preserves ``person_id``).""" + + max_faces_per_photo: int = 10 + """Maximum faces included in a callback payload (top-N by confidence; rest dropped).""" + + # --- Concurrency --- + thread_pool_size: int = 1 + """Number of threads in the ``ThreadPoolExecutor`` used for CPU-bound inference.""" + + workers: int = 1 + """Number of Uvicorn worker processes.""" + + # --- Embedding storage --- + storage_backend: str = "sqlite" + """Embedding storage engine: ``sqlite`` or ``pgvector``.""" + + storage_path: str = "/data/embeddings" + """SQLite DB directory (used when ``storage_backend = "sqlite"``).""" + + # --- PostgreSQL (pgvector) --- + pg_host: str = "localhost" + """PostgreSQL host (only when ``storage_backend = "pgvector"``).""" + + pg_port: int = 5432 + """PostgreSQL port.""" + + pg_database: str = "ai_vision" + """PostgreSQL database name.""" + + pg_user: str = "ai_vision" + """PostgreSQL username.""" + + pg_password: str = "" + """PostgreSQL password.""" + + # --- Photo volume --- + photos_path: str = "/data/photos" + """Shared Docker-volume mount point for photo files. + + ``photo_path`` values from Lychee are validated to reside within this prefix + (path-traversal protection). + """ + + # --- Logging --- + log_level: str = "info" + """Uvicorn/application log level.""" + + # --- Clustering --- + cluster_eps: float = 0.6 + """DBSCAN epsilon (max cosine distance) for face clustering. + Lower values produce tighter, more homogeneous clusters.""" + + # --- Quality filtering --- + blur_threshold: float = 0.5 + """Laplacian variance threshold for blur detection. + Face crops with a variance below this value are discarded before embedding.""" + + model_root: str = "/root/.insightface" + """Root directory for InsightFace model packs. Defaults to the library's default (``~/.insightface``) + but can be overridden to point to a shared Docker volume if desired.""" + + model_config = SettingsConfigDict( + env_prefix="VISION_FACE_", + # Support .env files in development but never require them in production. + # Load project root .env first (fallback), then working directory .env (override) + env_file=( + Path(__file__).parent.parent / ".env", # Project root (fallback) + ".env", # Current working directory (takes precedence) + ), + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", # Ignore extra fields (e.g., from Lychee's .env when running from main project) + ) + + +@lru_cache +def get_settings() -> AppSettings: + """Return a cached ``AppSettings`` instance. + + Call this function via ``Depends(get_settings)`` in FastAPI route handlers. + Override ``app.dependency_overrides[get_settings]`` in tests to inject + mock settings without touching environment variables. + """ + return AppSettings() # type: ignore[call-arg] diff --git a/ai-vision-service/face-recognition/app/detection/__init__.py b/ai-vision-service/face-recognition/app/detection/__init__.py new file mode 100644 index 00000000000..9dabc000d3e --- /dev/null +++ b/ai-vision-service/face-recognition/app/detection/__init__.py @@ -0,0 +1 @@ +"""Face detection and crop generation.""" diff --git a/ai-vision-service/face-recognition/app/detection/cropper.py b/ai-vision-service/face-recognition/app/detection/cropper.py new file mode 100644 index 00000000000..572e25437c8 --- /dev/null +++ b/ai-vision-service/face-recognition/app/detection/cropper.py @@ -0,0 +1,180 @@ +"""Face crop generation using Pillow. + +Produces 150 x 150 JPEG crops, base64-encoded, ready to embed in JSON payloads. +""" + +from __future__ import annotations + +import base64 +import io +from typing import TYPE_CHECKING + +from PIL import Image + +if TYPE_CHECKING: + from pathlib import Path + +CROP_SIZE: int = 150 +"""Output crop dimensions in pixels (square).""" + +_PADDING_FACTOR: float = 0.15 +"""Fractional padding added around each bounding box side before cropping.""" + + +def _calculate_square_crop_coords( + x: float, y: float, width: float, height: float, img_w: int, img_h: int, padding_factor: float +) -> tuple[int, int, int, int]: + """Calculate square crop coordinates centered on the face bounding box. + + Attempts to create a square crop centered on the face. If the square would + extend beyond image boundaries, it shifts the crop to fit. If the square is + larger than the image dimensions, the crop will be the maximum square that + fits within the image. + + Args: + x: Normalised left edge of the bounding box (0.0-1.0). + y: Normalised top edge of the bounding box (0.0-1.0). + width: Normalised bounding-box width (0.0-1.0). + height: Normalised bounding-box height (0.0-1.0). + img_w: Image width in pixels. + img_h: Image height in pixels. + padding_factor: Fractional padding to add around the bounding box. + + Returns: + Tuple of (x1, y1, x2, y2) defining a square crop region in absolute pixels. + """ + # Convert to absolute pixels + abs_x = x * img_w + abs_y = y * img_h + abs_w = width * img_w + abs_h = height * img_h + + # Add padding + pad_x = abs_w * padding_factor + pad_y = abs_h * padding_factor + + padded_x = abs_x - pad_x + padded_y = abs_y - pad_y + padded_w = abs_w + 2 * pad_x + padded_h = abs_h + 2 * pad_y + + # Determine square size (use the larger dimension) + square_size = max(padded_w, padded_h) + + # Cap square size to image dimensions (can't crop larger than the image) + max_possible_size = min(img_w, img_h) + square_size = min(square_size, max_possible_size) + + # Calculate center point of the padded bounding box + center_x = padded_x + padded_w / 2 + center_y = padded_y + padded_h / 2 + + # Calculate square crop coordinates centered on the face + x1 = center_x - square_size / 2 + y1 = center_y - square_size / 2 + x2 = center_x + square_size / 2 + y2 = center_y + square_size / 2 + + # Adjust to keep square within image boundaries + # If the square extends beyond the left edge, shift it right + if x1 < 0: + shift = -x1 + x1 = 0 + x2 = min(float(img_w), x2 + shift) + # If the square extends beyond the right edge, shift it left + if x2 > img_w: + shift = x2 - img_w + x2 = img_w + x1 = max(0.0, x1 - shift) + + # If the square extends beyond the top edge, shift it down + if y1 < 0: + shift = -y1 + y1 = 0 + y2 = min(float(img_h), y2 + shift) + # If the square extends beyond the bottom edge, shift it up + if y2 > img_h: + shift = y2 - img_h + y2 = img_h + y1 = max(0.0, y1 - shift) + + return int(x1), int(y1), int(x2), int(y2) + + +def _pad_to_square(img: Image.Image) -> Image.Image: + """Pad a non-square image to square with black borders. + + Args: + img: Input PIL Image. + + Returns: + Square PIL Image with black padding if needed. + """ + width, height = img.size + if width == height: + return img + + size = max(width, height) + square_img = Image.new("RGB", (size, size), (0, 0, 0)) + paste_x = (size - width) // 2 + paste_y = (size - height) // 2 + square_img.paste(img, (paste_x, paste_y)) + return square_img + + +def generate_crop(image_path: Path, x: float, y: float, width: float, height: float) -> str: + """Generate a base64-encoded 150 x 150 JPEG face crop. + + Args: + image_path: Absolute path to the source image. + x: Normalised left edge of the bounding box (0.0-1.0). + y: Normalised top edge of the bounding box (0.0-1.0). + width: Normalised bounding-box width (0.0-1.0). + height: Normalised bounding-box height (0.0-1.0). + + Returns: + Base64-encoded JPEG bytes (ASCII string). + """ + img = Image.open(image_path).convert("RGB") + img_w, img_h = img.size + + # Calculate square crop coordinates + x1, y1, x2, y2 = _calculate_square_crop_coords(x, y, width, height, img_w, img_h, _PADDING_FACTOR) + + # Crop and ensure it's square (pad if needed due to edge constraints) + crop = img.crop((x1, y1, x2, y2)) + crop = _pad_to_square(crop) + crop = crop.resize((CROP_SIZE, CROP_SIZE), Image.Resampling.LANCZOS) + + buf = io.BytesIO() + crop.save(buf, format="JPEG", quality=85) + return base64.b64encode(buf.getvalue()).decode("ascii") + + +def generate_crop_from_bytes(image_bytes: bytes, x: float, y: float, width: float, height: float) -> str: + """Generate a crop from raw image bytes (used in testing / matching). + + Args: + image_bytes: Raw image file bytes. + x: Normalised left edge of the bounding box (0.0-1.0). + y: Normalised top edge of the bounding box (0.0-1.0). + width: Normalised bounding-box width (0.0-1.0). + height: Normalised bounding-box height (0.0-1.0). + + Returns: + Base64-encoded JPEG bytes (ASCII string). + """ + img = Image.open(io.BytesIO(image_bytes)).convert("RGB") + img_w, img_h = img.size + + # Calculate square crop coordinates + x1, y1, x2, y2 = _calculate_square_crop_coords(x, y, width, height, img_w, img_h, _PADDING_FACTOR) + + # Crop and ensure it's square (pad if needed due to edge constraints) + crop = img.crop((x1, y1, x2, y2)) + crop = _pad_to_square(crop) + crop = crop.resize((CROP_SIZE, CROP_SIZE), Image.Resampling.LANCZOS) + + buf = io.BytesIO() + crop.save(buf, format="JPEG", quality=85) + return base64.b64encode(buf.getvalue()).decode("ascii") diff --git a/ai-vision-service/face-recognition/app/detection/detector.py b/ai-vision-service/face-recognition/app/detection/detector.py new file mode 100644 index 00000000000..560fdd63e77 --- /dev/null +++ b/ai-vision-service/face-recognition/app/detection/detector.py @@ -0,0 +1,230 @@ +"""InsightFace wrapper for face detection and embedding generation.""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +@dataclass +class DetectedFace: + """Raw face detection result, including the ArcFace embedding vector.""" + + x: float + """Left edge of bounding box as a fraction of image width (0.0-1.0).""" + + y: float + """Top edge of bounding box as a fraction of image height (0.0-1.0).""" + + width: float + """Bounding-box width as a fraction of image width (0.0-1.0).""" + + height: float + """Bounding-box height as a fraction of image height (0.0-1.0).""" + + confidence: float + """Detection confidence score from RetinaFace (0.0-1.0).""" + + embedding: list[float] = field(default_factory=list) + """512-dimensional ArcFace embedding vector.""" + + laplacian_variance: float = 0.0 + """Laplacian variance sharpness score (higher = sharper).""" + + +class FaceDetector: + """Thread-safe wrapper around InsightFace FaceAnalysis. + + Uses the ONNX Runtime CPU execution provider so no GPU is required. + The model is loaded once at startup (via :meth:`load`) and shared across + threads; a lock guards the non-thread-safe ``app.get()`` call. + """ + + def __init__( + self, + model_name: str = "buffalo_l", + detection_threshold: float = 0.5, + blur_threshold: float = 100.0, + model_root: str = "/root/.insightface", + ) -> None: + self._model_name = model_name + self._detection_threshold = detection_threshold + self._blur_threshold = blur_threshold + self._model_root = model_root + self._app: Any = None # insightface.app.FaceAnalysis - untyped library + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def load(self) -> None: + """Load the InsightFace model pack into memory. + + Idempotent - safe to call more than once. + Must be called before :meth:`detect` or :meth:`detect_bytes`. + """ + with self._lock: + if self._app is not None: + return + from insightface.app import FaceAnalysis + + app = FaceAnalysis( + name=self._model_name, + root=self._model_root, + providers=["CPUExecutionProvider"], + ) + app.prepare(ctx_id=-1) + self._app = app + + @property + def is_loaded(self) -> bool: + """Return ``True`` if the model has been successfully loaded.""" + return self._app is not None + + # ------------------------------------------------------------------ + # Detection + # ------------------------------------------------------------------ + + def detect(self, image_path: Path) -> list[DetectedFace]: + """Detect faces in an image file. + + Args: + image_path: Absolute path to the image file. + + Returns: + Detected faces sorted by descending confidence, with normalised + bounding box coordinates (0.0-1.0) and 512-dim embeddings. + + Raises: + RuntimeError: If :meth:`load` has not been called. + ValueError: If the file cannot be decoded as an image. + """ + import cv2 + + img: Any = cv2.imread(str(image_path)) + if img is None: + raise ValueError(f"Cannot read image: {image_path}") + return self._detect_array(img) + + def detect_bytes(self, image_bytes: bytes) -> list[DetectedFace]: + """Detect faces from raw image bytes. + + Useful when the caller already has the image in memory (e.g. for + selfie matching so that no temporary file needs to be written). + + Args: + image_bytes: Raw bytes of any image format supported by OpenCV. + + Returns: + Detected faces sorted by descending confidence. + + Raises: + RuntimeError: If :meth:`load` has not been called. + ValueError: If the bytes cannot be decoded as an image. + """ + import cv2 + import numpy as np + + nparr: Any = np.frombuffer(image_bytes, np.uint8) + img: Any = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + if img is None: + raise ValueError("Cannot decode image bytes") + return self._detect_array(img) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _detect_array(self, img: Any) -> list[DetectedFace]: + """Run detection on a BGR numpy array (internal).""" + import cv2 + + if self._app is None: + raise RuntimeError("FaceDetector not loaded - call load() first.") + + h: int = int(img.shape[0]) + w: int = int(img.shape[1]) + + with self._lock: + raw_faces: list[Any] = self._app.get(img) + + logger.info("Face detection: found %d raw face(s) in %dx%d image", len(raw_faces), w, h) + + results: list[DetectedFace] = [] + filtered_by_confidence = 0 + filtered_by_blur = 0 + + for face in raw_faces: + score: float = float(face.det_score) + if score < self._detection_threshold: + filtered_by_confidence += 1 + continue + + bbox: Any = face.bbox # [x1, y1, x2, y2] absolute pixels + x1, y1, x2, y2 = float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3]) + + # Compute Laplacian variance on the face crop region. + # This sharpness score is always computed and sent to Lychee for filtering/tuning. + px1 = max(0, int(x1)) + py1 = max(0, int(y1)) + px2 = min(w, int(x2)) + py2 = min(h, int(y2)) + variance: float = 0.0 + if px2 > px1 and py2 > py1: + crop_region = img[py1:py2, px1:px2] + gray = cv2.cvtColor(crop_region, cv2.COLOR_BGR2GRAY) + variance = float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + # Blur filter: exclude faces below threshold if enabled (threshold > 0.0) + if self._blur_threshold > 0.0 and variance < self._blur_threshold: + filtered_by_blur += 1 + logger.info( + "Filtered blurry face: variance=%.2f < threshold=%.2f", + variance, + self._blur_threshold, + ) + continue + + # Normalise to [0, 1] and clamp + fx = max(0.0, min(1.0, x1 / w)) + fy = max(0.0, min(1.0, y1 / h)) + fw = max(0.0, min(1.0, (x2 - x1) / w)) + fh = max(0.0, min(1.0, (y2 - y1) / h)) + + embedding: list[float] = [float(v) for v in face.embedding] + + results.append( + DetectedFace( + x=fx, + y=fy, + width=fw, + height=fh, + confidence=score, + embedding=embedding, + laplacian_variance=variance, + ) + ) + + # Descending confidence order + results.sort(key=lambda f: f.confidence, reverse=True) + + # Log summary + if filtered_by_confidence > 0 or filtered_by_blur > 0: + logger.info( + "Face detection: %d face(s) passed filters (filtered %d by confidence, %d by blur)", + len(results), + filtered_by_confidence, + filtered_by_blur, + ) + else: + logger.info("Face detection: %d face(s) detected (all passed filters)", len(results)) + + return results diff --git a/ai-vision-service/face-recognition/app/embeddings/__init__.py b/ai-vision-service/face-recognition/app/embeddings/__init__.py new file mode 100644 index 00000000000..f174c5f4101 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/__init__.py @@ -0,0 +1 @@ +"""Embedding storage layer.""" diff --git a/ai-vision-service/face-recognition/app/embeddings/factory.py b/ai-vision-service/face-recognition/app/embeddings/factory.py new file mode 100644 index 00000000000..5d737cd4390 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/factory.py @@ -0,0 +1,42 @@ +"""Factory for creating the appropriate EmbeddingStore backend.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.config import AppSettings + from app.embeddings.store import EmbeddingStore + + +def create_store(settings: AppSettings) -> EmbeddingStore: + """Return a configured :class:`EmbeddingStore` for the given settings. + + Args: + settings: Application settings (reads ``storage_backend``). + + Returns: + A ready-to-use :class:`EmbeddingStore` instance. + + Raises: + ValueError: If ``storage_backend`` is not ``"sqlite"`` or ``"pgvector"``. + """ + backend = settings.storage_backend.lower() + + if backend == "sqlite": + from app.embeddings.sqlite_store import SQLiteEmbeddingStore + + return SQLiteEmbeddingStore(storage_path=settings.storage_path) + + if backend == "pgvector": + from app.embeddings.pgvector_store import PgVectorEmbeddingStore + + return PgVectorEmbeddingStore( + host=settings.pg_host, + port=settings.pg_port, + database=settings.pg_database, + user=settings.pg_user, + password=settings.pg_password, + ) + + raise ValueError(f"Unknown storage_backend {backend!r}. Expected 'sqlite' or 'pgvector'.") diff --git a/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py b/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py new file mode 100644 index 00000000000..5748d12c478 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/pgvector_store.py @@ -0,0 +1,205 @@ +"""PostgreSQL + pgvector embedding storage backend. + +Requires the ``pgvector`` extension to be installed in the target database. +Use this backend for production-scale deployments where SQLite throughput +is a bottleneck. + +Table layout (single table, lychee_face_id as primary key): + face_embeddings: lychee_face_id TEXT PK, embedding vector(512) +""" + +from __future__ import annotations + +import threading +from typing import Any + +_EMBEDDING_DIM = 512 +"""ArcFace (buffalo_l) embedding dimension.""" + + +def _to_pg_vector(embedding: list[float]) -> str: + """Serialise a float list to the pgvector literal format ``[a,b,c,...]``.""" + return "[" + ",".join(f"{v:.8f}" for v in embedding) + "]" + + +class PgVectorEmbeddingStore: + """Embedding store backed by PostgreSQL + pgvector. + + Thread-safe: each method acquires an RLock to serialise access to the + connection pool (single connection for simplicity - swap for a real pool + in high-throughput deployments). + """ + + def __init__( + self, + host: str = "localhost", + port: int = 5432, + database: str = "ai_vision", + user: str = "ai_vision", + password: str = "", + ) -> None: + self._dsn = f"host={host} port={port} dbname={database} user={user} password={password}" + self._lock = threading.RLock() + self._conn: Any = None + self._init_db() + + # ------------------------------------------------------------------ + # EmbeddingStore protocol + # ------------------------------------------------------------------ + + def add( + self, + lychee_face_id: str, + embedding: list[float], + photo_id: str, + laplacian_variance: float, + crop_path: str, + ) -> None: + """Upsert an embedding row.""" + vec_str = _to_pg_vector(embedding) + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO face_embeddings (lychee_face_id, embedding, photo_id, laplacian_variance, crop_path) + VALUES (%s, %s::vector, %s, %s, %s) + ON CONFLICT (lychee_face_id) DO UPDATE + SET embedding = EXCLUDED.embedding, + photo_id = EXCLUDED.photo_id, + laplacian_variance = EXCLUDED.laplacian_variance, + crop_path = EXCLUDED.crop_path + """, + (lychee_face_id, vec_str, photo_id, laplacian_variance, crop_path), + ) + conn.commit() + + def delete(self, lychee_face_id: str) -> None: + """Remove an embedding by Lychee Face ID.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + "DELETE FROM face_embeddings WHERE lychee_face_id = %s", + (lychee_face_id,), + ) + conn.commit() + + def similarity_search( + self, + embedding: list[float], + threshold: float, + limit: int = 10, + ) -> list[tuple[str, float]]: + """Cosine-similarity search using pgvector's ``<=>`` operator.""" + vec_str = _to_pg_vector(embedding) + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + """ + WITH q AS (SELECT %s::vector AS qvec) + SELECT f.lychee_face_id, + 1.0 - (f.embedding <=> q.qvec) AS similarity + FROM face_embeddings f, q + WHERE 1.0 - (f.embedding <=> q.qvec) >= %s + ORDER BY f.embedding <=> q.qvec + LIMIT %s + """, + (vec_str, threshold, limit), + ) + rows: list[Any] = cur.fetchall() + return [(row[0], float(row[1])) for row in rows] + + def delete_many(self, lychee_face_ids: list[str]) -> int: + """Remove multiple embeddings by Lychee Face ID.""" + if not lychee_face_ids: + return 0 + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + # Use ANY(%s) with a list parameter for batch delete + cur.execute( + "DELETE FROM face_embeddings WHERE lychee_face_id = ANY(%s)", + (lychee_face_ids,), + ) + deleted: int = cur.rowcount + conn.commit() + return deleted + + def get_all(self) -> list[tuple[str, list[float]]]: + """Return all stored embeddings as (face_id, embedding) pairs.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("SELECT lychee_face_id, embedding FROM face_embeddings") + rows: list[Any] = cur.fetchall() + return [(row[0], [float(v) for v in row[1]]) for row in rows] + + def get_all_with_metadata(self) -> list[dict[str, str | float | None]]: + """Return all stored embeddings with metadata.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute( + "SELECT lychee_face_id, photo_id, laplacian_variance, crop_path FROM face_embeddings" + ) + rows: list[Any] = cur.fetchall() + results: list[dict[str, str | float | None]] = [] + for row in rows: + results.append({ + "lychee_face_id": row[0], + "photo_id": row[1], + "laplacian_variance": float(row[2]) if row[2] is not None else None, + "crop_path": row[3], + }) + return results + + def count(self) -> int: + """Return the total number of stored embeddings.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("SELECT COUNT(*) FROM face_embeddings") + row: Any = cur.fetchone() + return int(row[0]) if row else 0 + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_conn(self) -> Any: + """Return the shared connection, reconnecting if needed.""" + import psycopg2 + + if self._conn is None or self._conn.closed: + self._conn = psycopg2.connect(self._dsn) + return self._conn + + def _init_db(self) -> None: + """Create the table and index if they do not already exist.""" + with self._lock: + conn = self._get_conn() + with conn.cursor() as cur: + cur.execute("CREATE EXTENSION IF NOT EXISTS vector") + cur.execute( + f""" + CREATE TABLE IF NOT EXISTS face_embeddings ( + lychee_face_id TEXT PRIMARY KEY, + embedding vector({_EMBEDDING_DIM}) NOT NULL, + photo_id TEXT, + laplacian_variance REAL, + crop_path TEXT + ) + """ + ) + # IVFFlat index for approx nearest-neighbour search. + # ``lists`` should be tuned (~sqrt of row count). + cur.execute( + """ + CREATE INDEX IF NOT EXISTS face_embeddings_embedding_cosine_idx + ON face_embeddings USING ivfflat (embedding vector_cosine_ops) + WITH (lists = 100) + """ + ) + conn.commit() diff --git a/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py new file mode 100644 index 00000000000..3ca6d662143 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py @@ -0,0 +1,243 @@ +"""SQLite + sqlite-vec embedding storage backend. + +Uses the sqlite-vec extension for approximate nearest-neighbour (ANN) search +over 512-dimensional float vectors. This is the default backend, suitable +for single-container deployments. + +Table layout: + vec_faces (vec0 virtual table) - stores raw float vectors; indexed by rowid. + face_meta (regular table) - stores lychee_face_id -> vec_rowid mapping. +""" + +from __future__ import annotations + +import sqlite3 +import struct +import threading +from pathlib import Path + +_EMBEDDING_DIM = 512 +"""ArcFace (buffalo_l) embedding dimension.""" + + +def _to_blob(embedding: list[float]) -> bytes: + """Serialise a float list to a little-endian float32 byte blob.""" + return struct.pack(f"{len(embedding)}f", *embedding) + + +class SQLiteEmbeddingStore: + """Embedding store backed by SQLite + sqlite-vec. + + Thread-safe: all write operations are protected by a reentrant lock. + """ + + def __init__(self, storage_path: str) -> None: + """Initialise the store. + + Args: + storage_path: Directory where the SQLite database file will be + created (filename ``embeddings.db``). + """ + db_dir = Path(storage_path) + db_dir.mkdir(parents=True, exist_ok=True) + self._db_path = str(db_dir / "embeddings.db") + self._lock = threading.RLock() + self._init_db() + + # ------------------------------------------------------------------ + # EmbeddingStore protocol + # ------------------------------------------------------------------ + + def add( + self, + lychee_face_id: str, + embedding: list[float], + photo_id: str, + laplacian_variance: float, + crop_path: str, + ) -> None: + """Upsert an embedding row.""" + with self._lock: + conn = self._connect() + try: + # Remove existing entry for this face (if any) before inserting. + self._delete_internal(conn, lychee_face_id) + cursor = conn.execute( + "INSERT INTO vec_faces(face_embedding) VALUES (?)", + [_to_blob(embedding)], + ) + vec_rowid: int = cursor.lastrowid # type: ignore[assignment] + conn.execute( + "INSERT INTO face_meta(vec_rowid, lychee_face_id, photo_id, laplacian_variance, crop_path) VALUES (?, ?, ?, ?, ?)", + [vec_rowid, lychee_face_id, photo_id, laplacian_variance, crop_path], + ) + conn.commit() + finally: + conn.close() + + def delete(self, lychee_face_id: str) -> None: + """Remove an embedding by Lychee Face ID.""" + with self._lock: + conn = self._connect() + try: + self._delete_internal(conn, lychee_face_id) + conn.commit() + finally: + conn.close() + + def similarity_search( + self, + embedding: list[float], + threshold: float, + limit: int = 10, + ) -> list[tuple[str, float]]: + """KNN cosine-similarity search. + + Fetches up to ``limit * 5`` candidates from vec0 (to allow for + threshold filtering) and returns at most ``limit`` results that + exceed ``threshold``. + """ + conn = self._connect() + try: + k = limit * 5 # over-fetch so we have headroom after threshold filter + rows = conn.execute( + """ + SELECT m.lychee_face_id, v.distance + FROM vec_faces v + JOIN face_meta m ON m.vec_rowid = v.rowid + WHERE v.face_embedding MATCH ? AND k = ? + ORDER BY v.distance + """, + [_to_blob(embedding), k], + ).fetchall() + + results: list[tuple[str, float]] = [] + for lychee_face_id, distance in rows: + similarity = 1.0 - float(distance) + if similarity >= threshold: + results.append((lychee_face_id, similarity)) + if len(results) >= limit: + break + return results + finally: + conn.close() + + def delete_many(self, lychee_face_ids: list[str]) -> int: + """Remove multiple embeddings by Lychee Face ID.""" + if not lychee_face_ids: + return 0 + deleted = 0 + with self._lock: + conn = self._connect() + try: + for fid in lychee_face_ids: + row = conn.execute( + "SELECT vec_rowid FROM face_meta WHERE lychee_face_id = ?", + [fid], + ).fetchone() + if row is not None: + conn.execute("DELETE FROM vec_faces WHERE rowid = ?", [row[0]]) + conn.execute("DELETE FROM face_meta WHERE lychee_face_id = ?", [fid]) + deleted += 1 + conn.commit() + finally: + conn.close() + return deleted + + def get_all(self) -> list[tuple[str, list[float]]]: + """Return all stored embeddings as (face_id, embedding) pairs.""" + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT m.lychee_face_id, v.face_embedding + FROM face_meta m + JOIN vec_faces v ON v.rowid = m.vec_rowid + """, + ).fetchall() + results: list[tuple[str, list[float]]] = [] + for lychee_face_id, blob in rows: + count = len(blob) // 4 # float32 = 4 bytes + embedding = list(struct.unpack(f"{count}f", blob)) + results.append((lychee_face_id, embedding)) + return results + finally: + conn.close() + + def get_all_with_metadata(self) -> list[dict[str, str | float | None]]: + """Return all stored embeddings with metadata.""" + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT lychee_face_id, photo_id, laplacian_variance, crop_path + FROM face_meta + """, + ).fetchall() + results: list[dict[str, str | float | None]] = [] + for lychee_face_id, photo_id, laplacian_variance, crop_path in rows: + results.append({ + "lychee_face_id": lychee_face_id, + "photo_id": photo_id, + "laplacian_variance": laplacian_variance, + "crop_path": crop_path, + }) + return results + finally: + conn.close() + + def count(self) -> int: + """Return the number of stored embeddings.""" + conn = self._connect() + try: + row = conn.execute("SELECT COUNT(*) FROM face_meta").fetchone() + return int(row[0]) if row else 0 + finally: + conn.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _connect(self) -> sqlite3.Connection: + """Open a new SQLite connection with sqlite-vec loaded.""" + import sqlite_vec + + conn = sqlite3.connect(self._db_path, check_same_thread=False) + conn.enable_load_extension(True) + sqlite_vec.load(conn) + conn.enable_load_extension(False) + return conn + + def _init_db(self) -> None: + """Create tables if they do not already exist.""" + with self._lock: + conn = self._connect() + try: + conn.execute( + f"CREATE VIRTUAL TABLE IF NOT EXISTS vec_faces USING vec0(face_embedding float[{_EMBEDDING_DIM}])" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS face_meta ( + vec_rowid INTEGER UNIQUE NOT NULL, + lychee_face_id TEXT PRIMARY KEY NOT NULL, + photo_id TEXT, + laplacian_variance REAL, + crop_path TEXT + ) + """ + ) + conn.commit() + finally: + conn.close() + + def _delete_internal(self, conn: sqlite3.Connection, lychee_face_id: str) -> None: + """Delete an entry without committing (caller must commit).""" + row = conn.execute( + "SELECT vec_rowid FROM face_meta WHERE lychee_face_id = ?", + [lychee_face_id], + ).fetchone() + if row is not None: + conn.execute("DELETE FROM vec_faces WHERE rowid = ?", [row[0]]) + conn.execute("DELETE FROM face_meta WHERE lychee_face_id = ?", [lychee_face_id]) diff --git a/ai-vision-service/face-recognition/app/embeddings/store.py b/ai-vision-service/face-recognition/app/embeddings/store.py new file mode 100644 index 00000000000..0f56f11eb47 --- /dev/null +++ b/ai-vision-service/face-recognition/app/embeddings/store.py @@ -0,0 +1,103 @@ +"""Abstract EmbeddingStore protocol. + +All embedding storage backends must conform to this interface so that the +application code is independent of the concrete storage engine chosen. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class EmbeddingStore(Protocol): + """Protocol for face embedding storage backends. + + Implementations must be thread-safe when called from concurrent request + handlers. + """ + + def add( + self, + lychee_face_id: str, + embedding: list[float], + photo_id: str, + laplacian_variance: float, + crop_path: str, + ) -> None: + """Persist an embedding, keyed by its Lychee Face ID. + + If an entry for ``lychee_face_id`` already exists, it is replaced. + + Args: + lychee_face_id: Stable Lychee ``Face.id`` (string PK). + embedding: 512-dimensional ArcFace float vector. + photo_id: Lychee photo ID for re-synchronization support. + laplacian_variance: Sharpness score for filtering/tuning. + crop_path: Relative path to stored face crop (e.g. 'faces/{id}.jpg'). + """ + ... + + def delete(self, lychee_face_id: str) -> None: + """Remove an embedding by Lychee Face ID. + + No-op if the ID is not found. + + Args: + lychee_face_id: Stable Lychee ``Face.id`` to remove. + """ + ... + + def similarity_search( + self, + embedding: list[float], + threshold: float, + limit: int = 10, + ) -> list[tuple[str, float]]: + """Return the most similar stored faces. + + Args: + embedding: Query embedding vector. + threshold: Minimum cosine-similarity score (0.0-1.0). + limit: Maximum number of results to return. + + Returns: + List of ``(lychee_face_id, similarity)`` tuples ordered by + descending similarity score. Only entries above ``threshold`` + are included. + """ + ... + + def delete_many(self, lychee_face_ids: list[str]) -> int: + """Remove multiple embeddings by Lychee Face ID. + + Args: + lychee_face_ids: List of Lychee ``Face.id`` strings to remove. + + Returns: + Number of embeddings actually deleted (IDs not found are silently + skipped). + """ + ... + + def get_all(self) -> list[tuple[str, list[float]]]: + """Return all stored embeddings. + + Returns: + List of ``(lychee_face_id, embedding)`` pairs. Used by the + clustering endpoint to read the full dataset into memory. + """ + ... + + def get_all_with_metadata(self) -> list[dict[str, str | float | None]]: + """Return all stored embeddings with metadata. + + Returns: + List of dicts with keys: lychee_face_id, photo_id, laplacian_variance, crop_path. + Used for syncing embeddings back to Lychee. + """ + ... + + def count(self) -> int: + """Return the total number of stored embeddings.""" + ... diff --git a/ai-vision-service/face-recognition/app/main.py b/ai-vision-service/face-recognition/app/main.py new file mode 100644 index 00000000000..78065a194e8 --- /dev/null +++ b/ai-vision-service/face-recognition/app/main.py @@ -0,0 +1,126 @@ +"""FastAPI application factory. + +Entry point for the AI Vision Service. The ``create_app`` factory accepts an +optional ``lifespan`` parameter so that tests can inject a custom lifespan +context that pre-populates ``app.state`` with mock objects instead of loading +the real InsightFace model. +""" + +from __future__ import annotations + +import logging +import warnings +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +import httpx +from fastapi import FastAPI + +from app.api.routes import router +from app.config import AppSettings, get_settings + +# insightface uses the deprecated SimilarityTransform.estimate() API from scikit-image >=0.26. +# Suppress until insightface ships a fix upstream. +warnings.filterwarnings( + "ignore", + message=r"`estimate` is deprecated", + category=FutureWarning, + module=r"skimage", +) + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def _default_lifespan(app: FastAPI) -> AsyncGenerator[None]: + """Production lifespan: load the face model and initialise the store.""" + settings: AppSettings = get_settings() + + # Configure logging + logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) + + # Verify Lychee connectivity + lychee_up_url = f"{settings.lychee_api_url}/up" + logger.info("Checking Lychee connectivity at %s", lychee_up_url) + try: + async with httpx.AsyncClient(verify=settings.verify_ssl, timeout=10.0) as client: + response = await client.get(lychee_up_url) + response.raise_for_status() + logger.info("✓ Lychee is reachable (status=%d)", response.status_code) + except httpx.HTTPStatusError as e: + logger.error("✗ Lychee /up endpoint returned status %d", e.response.status_code) + raise RuntimeError( + f"Lychee health check failed: /up returned {e.response.status_code}. " + "Ensure VISION_FACE_LYCHEE_API_URL is correct and Lychee is running." + ) from e + except httpx.RequestError as e: + logger.error("✗ Cannot connect to Lychee at %s: %s", lychee_up_url, e) + raise RuntimeError( + f"Cannot connect to Lychee at {lychee_up_url}. " + "Ensure VISION_FACE_LYCHEE_API_URL is correct and Lychee is reachable." + ) from e + + # Load detector + from app.detection.detector import FaceDetector + + detector = FaceDetector( + model_name=settings.model_name, + detection_threshold=settings.detection_threshold, + blur_threshold=settings.blur_threshold, + model_root=settings.model_root, + ) + detector.load() + logger.info("InsightFace model '%s' loaded successfully", settings.model_name) + + # Initialise embedding store + from app.embeddings.factory import create_store + + store = create_store(settings) + logger.info( + "Embedding store initialised (backend=%s, count=%d)", + settings.storage_backend, + store.count(), + ) + + # Thread pool for CPU-bound inference + executor = ThreadPoolExecutor(max_workers=settings.thread_pool_size) + + app.state.detector = detector + app.state.store = store + app.state.executor = executor + + yield + + executor.shutdown(wait=False) + logger.info("AI Vision Service shut down") + + +def create_app(lifespan: Any = None) -> FastAPI: + """Create and configure the FastAPI application. + + Args: + lifespan: Optional async context manager to use as the application + lifespan. When ``None``, :func:`_default_lifespan` is used. + Override in tests to inject mock state without loading the model. + + Returns: + A configured :class:`fastapi.FastAPI` instance. + """ + used_lifespan = lifespan if lifespan is not None else _default_lifespan + + application = FastAPI( + title="Lychee AI Vision Service", + description="Facial recognition microservice for Lychee photo gallery.", + version="0.1.0", + lifespan=used_lifespan, + ) + application.include_router(router) + return application + + +# Module-level app instance used by uvicorn when started via the Dockerfile CMD. +app: FastAPI = create_app() diff --git a/ai-vision-service/face-recognition/app/matching/__init__.py b/ai-vision-service/face-recognition/app/matching/__init__.py new file mode 100644 index 00000000000..ba5a1fe05de --- /dev/null +++ b/ai-vision-service/face-recognition/app/matching/__init__.py @@ -0,0 +1 @@ +"""Selfie-to-face similarity matching.""" diff --git a/ai-vision-service/face-recognition/app/matching/matcher.py b/ai-vision-service/face-recognition/app/matching/matcher.py new file mode 100644 index 00000000000..8b7e1c907e8 --- /dev/null +++ b/ai-vision-service/face-recognition/app/matching/matcher.py @@ -0,0 +1,62 @@ +"""Selfie-to-face similarity matching. + +Accepts a selfie image (as raw bytes), detects the face, embeds it, and +returns the best matching Lychee Face IDs from the embedding store. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.api.schemas import MatchResponse, MatchResult + +if TYPE_CHECKING: + from app.detection.detector import FaceDetector + from app.embeddings.store import EmbeddingStore + + +class FaceMatcher: + """Encapsulates the selfie-match logic used by the ``POST /match`` route. + + Args: + detector: Loaded :class:`~app.detection.detector.FaceDetector`. + store: Configured :class:`~app.embeddings.store.EmbeddingStore`. + threshold: Minimum cosine-similarity score for a match to be included. + """ + + def __init__( + self, + detector: FaceDetector, + store: EmbeddingStore, + threshold: float = 0.5, + ) -> None: + self._detector = detector + self._store = store + self._threshold = threshold + + def match(self, image_bytes: bytes, limit: int = 10) -> MatchResponse: + """Run selfie matching. + + Args: + image_bytes: Raw bytes of the uploaded selfie image. + limit: Maximum number of matches to return. + + Returns: + :class:`~app.api.schemas.MatchResponse` with matches ordered by + descending confidence. The list may be empty if no stored face + exceeds the threshold. + + Raises: + ValueError: If no face is detected in the selfie. + """ + raw_faces = self._detector.detect_bytes(image_bytes) + if not raw_faces: + raise ValueError("No face detected in the selfie image.") + + # Use the highest-confidence face for matching. + best = raw_faces[0] + matches = self._store.similarity_search(best.embedding, self._threshold, limit=limit) + + return MatchResponse( + matches=[MatchResult(lychee_face_id=face_id, confidence=conf) for face_id, conf in matches] + ) diff --git a/ai-vision-service/face-recognition/data/.gitignore b/ai-vision-service/face-recognition/data/.gitignore new file mode 100644 index 00000000000..c96a04f008e --- /dev/null +++ b/ai-vision-service/face-recognition/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ai-vision-service/face-recognition/pyproject.toml b/ai-vision-service/face-recognition/pyproject.toml new file mode 100644 index 00000000000..84c2641aae5 --- /dev/null +++ b/ai-vision-service/face-recognition/pyproject.toml @@ -0,0 +1,91 @@ +[project] +name = "ai-vision-service" +version = "0.1.0" +description = "Lychee AI Vision Service – facial recognition microservice" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "pydantic>=2.10", + "pydantic-settings>=2.7", + "httpx>=0.28", + "insightface>=0.7", + "onnxruntime>=1.20", + "opencv-python-headless>=4.10", + "pillow>=11.0", + "numpy>=2.2", + "scikit-learn>=1.6", + "sqlite-vec>=0.1", + "psycopg2-binary>=2.9", + "python-multipart>=0.0.20", +] + +[project.optional-dependencies] +gpu = ["onnxruntime-gpu>=1.20"] + +[dependency-groups] +dev = [ + "ruff>=0.9", + "ty>=0.0.1a10", + "pytest>=8.3", + "pytest-cov>=6.0", + "pytest-asyncio>=0.25", + "anyio>=4.7", + "respx>=0.21", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +# --------------------------------------------------------------------------- +# Ruff – linting and formatting +# --------------------------------------------------------------------------- +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "ANN", # flake8-annotations + "B", # flake8-bugbear + "A", # flake8-builtins + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "RUF", # Ruff-specific rules +] +ignore = [ + "B008", # FastAPI requires Depends() calls in function default arguments + "ANN401", # typing.Any needed for InsightFace's untyped library interface +] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +# --------------------------------------------------------------------------- +# ty – type checking +# --------------------------------------------------------------------------- +[tool.ty] +# ty inherits Python version from requires-python + +# --------------------------------------------------------------------------- +# pytest +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "--strict-markers" diff --git a/ai-vision-service/face-recognition/tests/__init__.py b/ai-vision-service/face-recognition/tests/__init__.py new file mode 100644 index 00000000000..4e7c0ac83f7 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for the AI Vision Service.""" diff --git a/ai-vision-service/face-recognition/tests/conftest.py b/ai-vision-service/face-recognition/tests/conftest.py new file mode 100644 index 00000000000..1a4929602ca --- /dev/null +++ b/ai-vision-service/face-recognition/tests/conftest.py @@ -0,0 +1,157 @@ +"""Shared pytest fixtures for the AI Vision Service test suite.""" + +from __future__ import annotations + +import io +from concurrent.futures import ThreadPoolExecutor +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient +from PIL import Image + +from app.config import AppSettings, get_settings +from app.detection.detector import DetectedFace, FaceDetector +from app.embeddings.store import EmbeddingStore +from app.main import create_app + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from fastapi import FastAPI + +# --------------------------------------------------------------------------- +# Environment / settings +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_settings() -> AppSettings: + """Return an ``AppSettings``-like mock pre-populated with test values.""" + m = MagicMock(spec=AppSettings) + m.api_key = "test-api-key" + m.lychee_api_url = "http://lychee-test" + m.photos_path = "/tmp" # overridden where needed + m.match_threshold = 0.5 + m.max_faces_per_photo = 10 + m.detection_threshold = 0.5 + m.thread_pool_size = 1 + m.storage_backend = "sqlite" + m.log_level = "info" + return m + + +# --------------------------------------------------------------------------- +# Detector mock +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_detector() -> FaceDetector: + """Return a mock :class:`FaceDetector` that returns no faces by default.""" + m = MagicMock(spec=FaceDetector) + m.is_loaded = True + m.detect.return_value = [] + m.detect_bytes.return_value = [] + return m # type: ignore[return-value] + + +@pytest.fixture +def detected_face() -> DetectedFace: + """Return a sample :class:`DetectedFace` for use in tests.""" + return DetectedFace( + x=0.1, + y=0.2, + width=0.3, + height=0.4, + confidence=0.95, + embedding=[0.1] * 512, + ) + + +# --------------------------------------------------------------------------- +# Store mock +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_store() -> EmbeddingStore: + """Return a mock :class:`EmbeddingStore` with sensible defaults.""" + m = MagicMock(spec=EmbeddingStore) + m.count.return_value = 0 + m.similarity_search.return_value = [] + return m # type: ignore[return-value] + + +# --------------------------------------------------------------------------- +# FastAPI test client +# --------------------------------------------------------------------------- + + +@pytest.fixture +def test_app(mock_detector: FaceDetector, mock_store: EmbeddingStore, tmp_path: Any) -> FastAPI: + """Return a FastAPI app wired with mock state (no real model loaded).""" + + @asynccontextmanager + async def _test_lifespan(app: FastAPI) -> AsyncGenerator[None]: + app.state.detector = mock_detector + app.state.store = mock_store + app.state.executor = ThreadPoolExecutor(max_workers=1) + yield + + application = create_app(lifespan=_test_lifespan) + + # Override settings so required env vars are not needed + def _override_settings() -> AppSettings: + m = MagicMock(spec=AppSettings) + m.api_key = "test-api-key" + m.lychee_api_url = "http://lychee-test" + m.photos_path = str(tmp_path) + m.match_threshold = 0.5 + m.max_faces_per_photo = 10 + m.detection_threshold = 0.5 + m.thread_pool_size = 1 + return m # type: ignore[return-value] + + application.dependency_overrides[get_settings] = _override_settings + return application + + +@pytest.fixture +def client(test_app: FastAPI) -> TestClient: + """Return a synchronous :class:`TestClient` bound to the test app.""" + with TestClient(test_app) as c: + return c + + +@pytest.fixture +def photos_path(test_app: FastAPI) -> Path: + """Return the photos_path directory configured in the test app's settings override.""" + settings: AppSettings = test_app.dependency_overrides[get_settings]() + return Path(settings.photos_path) + + +# --------------------------------------------------------------------------- +# Synthetic test image +# --------------------------------------------------------------------------- + + +@pytest.fixture +def jpeg_image_bytes() -> bytes: + """Return raw JPEG bytes for a simple 100x100 green image.""" + img = Image.new("RGB", (100, 100), color=(34, 139, 34)) + buf = io.BytesIO() + img.save(buf, format="JPEG") + return buf.getvalue() + + +@pytest.fixture +def jpeg_image_path(tmp_path: Any, jpeg_image_bytes: bytes) -> Any: + """Write a JPEG test image to a temp path and return the :class:`Path`.""" + + path: Path = tmp_path / "test_photo.jpg" + path.write_bytes(jpeg_image_bytes) + return path diff --git a/ai-vision-service/face-recognition/tests/test_api.py b/ai-vision-service/face-recognition/tests/test_api.py new file mode 100644 index 00000000000..3c3dc2b53a5 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_api.py @@ -0,0 +1,181 @@ +"""Tests for FastAPI routes (app.api.routes).""" + +from __future__ import annotations + +import io +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from app.detection.detector import DetectedFace + +if TYPE_CHECKING: + from pathlib import Path + + from fastapi.testclient import TestClient + +# --------------------------------------------------------------------------- +# GET /health +# --------------------------------------------------------------------------- + + +def test_health_returns_ok(client: TestClient, mock_detector: object, mock_store: object) -> None: + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["model_loaded"] is True + assert data["status"] == "ok" + assert isinstance(data["embedding_count"], int) + + +def test_health_is_unauthenticated(client: TestClient) -> None: + """GET /health must not require X-API-Key.""" + response = client.get("/health") + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Authentication +# --------------------------------------------------------------------------- + + +def test_detect_requires_api_key(client: TestClient, tmp_path: Path) -> None: + photo = tmp_path / "photo.jpg" + photo.touch() + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": str(photo)}, + ) + assert response.status_code == 422 # missing header + + +def test_detect_rejects_wrong_api_key(client: TestClient, tmp_path: Path) -> None: + photo = tmp_path / "photo.jpg" + photo.touch() + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": str(photo)}, + headers={"X-API-Key": "wrong-key"}, + ) + assert response.status_code == 401 + + +def test_match_requires_api_key(client: TestClient) -> None: + img_buf = io.BytesIO(b"fake-image") + response = client.post("/match", files={"file": ("selfie.jpg", img_buf, "image/jpeg")}) + assert response.status_code == 422 # missing header + + +# --------------------------------------------------------------------------- +# POST /detect +# --------------------------------------------------------------------------- + + +def test_detect_returns_202(client: TestClient, photos_path: Path) -> None: + """Valid request to POST /detect must return 202 Accepted.""" + photo = photos_path / "photo.jpg" + photo.touch() + + with patch("app.api.routes._run_detection_job") as mock_job: + response = client.post( + "/detect", + json={"photo_id": "photo-123", "photo_path": "photo.jpg"}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 202 + mock_job.assert_called_once() + + +def test_detect_rejects_path_traversal(client: TestClient, tmp_path: Path) -> None: + """photo_path outside photos_path must be rejected with 400.""" + # The test app sets photos_path = tmp_path; /etc is outside it + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": "/etc/passwd"}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 400 + + +def test_detect_rejects_nonexistent_file(client: TestClient) -> None: + """photo_path that does not exist must be rejected with 400.""" + response = client.post( + "/detect", + json={"photo_id": "p1", "photo_path": "missing.jpg"}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 400 + + +def test_detect_background_task_called_with_correct_args(client: TestClient, photos_path: Path) -> None: + photo = photos_path / "photo.jpg" + photo.touch() + + with patch("app.api.routes._run_detection_job") as mock_job: + client.post( + "/detect", + json={"photo_id": "photo-xyz", "photo_path": "photo.jpg"}, + headers={"X-API-Key": "test-api-key"}, + ) + args = mock_job.call_args[0] + assert args[0] == "photo-xyz" + assert args[1] == photo.resolve() + + +# --------------------------------------------------------------------------- +# POST /match +# --------------------------------------------------------------------------- + + +def test_match_returns_422_when_no_face(client: TestClient, mock_detector: object, jpeg_image_bytes: bytes) -> None: + """When the detector finds no face, /match must return 422.""" + # mock_detector.detect_bytes already returns [] by default + img_buf = io.BytesIO(jpeg_image_bytes) + response = client.post( + "/match", + files={"file": ("selfie.jpg", img_buf, "image/jpeg")}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 422 + + +def test_match_returns_matches( + client: TestClient, mock_detector: object, mock_store: object, jpeg_image_bytes: bytes +) -> None: + """When matches are found, /match must return them in the response body.""" + + mock_detector.detect_bytes.return_value = [ # type: ignore[attr-defined] + DetectedFace(x=0.1, y=0.1, width=0.5, height=0.5, confidence=0.99, embedding=[0.5] * 512) + ] + mock_store.similarity_search.return_value = [("face-abc", 0.91)] # type: ignore[attr-defined] + + img_buf = io.BytesIO(jpeg_image_bytes) + response = client.post( + "/match", + files={"file": ("selfie.jpg", img_buf, "image/jpeg")}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["matches"][0]["lychee_face_id"] == "face-abc" + assert data["matches"][0]["confidence"] == pytest.approx(0.91) + + +def test_match_returns_empty_matches_when_below_threshold( + client: TestClient, mock_detector: object, mock_store: object, jpeg_image_bytes: bytes +) -> None: + """If no stored face exceeds threshold, matches must be an empty list.""" + mock_detector.detect_bytes.return_value = [ # type: ignore[attr-defined] + DetectedFace(x=0.1, y=0.1, width=0.5, height=0.5, confidence=0.99, embedding=[0.5] * 512) + ] + mock_store.similarity_search.return_value = [] # type: ignore[attr-defined] + + img_buf = io.BytesIO(jpeg_image_bytes) + response = client.post( + "/match", + files={"file": ("selfie.jpg", img_buf, "image/jpeg")}, + headers={"X-API-Key": "test-api-key"}, + ) + assert response.status_code == 200 + assert response.json()["matches"] == [] diff --git a/ai-vision-service/face-recognition/tests/test_clustering.py b/ai-vision-service/face-recognition/tests/test_clustering.py new file mode 100644 index 00000000000..6ef54272461 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_clustering.py @@ -0,0 +1,111 @@ +"""Tests for app.clustering.clusterer.""" + +from __future__ import annotations + +import math + +from app.clustering.clusterer import FaceClusterer + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _unit_vec(index: int = 0, dim: int = 512) -> list[float]: + v = [0.0] * dim + v[index] = 1.0 + return v + + +def _similar_vec(base_index: int, noise_index: int, noise: float = 0.05) -> list[float]: + """Return a vector close to the unit-vector at ``base_index``.""" + v = [0.0] * 512 + v[base_index] = 1.0 + v[noise_index] = noise + norm = math.sqrt(1.0 + noise**2) + return [x / norm for x in v] + + +# --------------------------------------------------------------------------- +# FaceClusterer.cluster() +# --------------------------------------------------------------------------- + + +def test_empty_input_returns_empty() -> None: + clusterer = FaceClusterer() + assert clusterer.cluster([]) == [] + + +def test_single_face_gets_cluster_zero() -> None: + """With min_samples=1 a single face should form cluster 0 (not noise).""" + clusterer = FaceClusterer(eps=0.4, min_samples=1) + result = clusterer.cluster([("face-1", _unit_vec(0))]) + assert result == [("face-1", 0)] + + +def test_identical_faces_in_same_cluster() -> None: + """Two identical embeddings must land in the same cluster.""" + clusterer = FaceClusterer(eps=0.4, min_samples=1) + vec = _unit_vec(0) + result = clusterer.cluster([("face-1", vec.copy()), ("face-2", vec.copy())]) + labels = {fid: label for fid, label in result} + assert labels["face-1"] == labels["face-2"] + + +def test_distinct_faces_in_different_clusters() -> None: + """Orthogonal embeddings must land in separate clusters with tight eps.""" + clusterer = FaceClusterer(eps=0.1, min_samples=1) + result = clusterer.cluster( + [ + ("face-a", _unit_vec(0)), + ("face-b", _unit_vec(1)), + ("face-c", _unit_vec(2)), + ] + ) + labels = {fid: label for fid, label in result} + # All three should be in different clusters (no two share a label) + assert len(set(labels.values())) == 3 + + +def test_similar_faces_grouped_together() -> None: + """Slightly varied embeddings of the same person must form one cluster.""" + base = _unit_vec(0) + similar_1 = _similar_vec(0, 10, noise=0.05) + similar_2 = _similar_vec(0, 20, noise=0.05) + clusterer = FaceClusterer(eps=0.3, min_samples=1) + result = clusterer.cluster( + [ + ("face-1", base), + ("face-2", similar_1), + ("face-3", similar_2), + ] + ) + labels = {fid: label for fid, label in result} + assert labels["face-1"] == labels["face-2"] == labels["face-3"] + + +def test_output_preserves_order() -> None: + """Output IDs must appear in the same order as the input.""" + clusterer = FaceClusterer() + ids = [f"face-{i}" for i in range(10)] + embeddings = [(fid, _unit_vec(i % 512)) for i, fid in enumerate(ids)] + result = clusterer.cluster(embeddings) + assert [fid for fid, _ in result] == ids + + +def test_two_clusters_noise_excluded() -> None: + """With min_samples=2 an isolated point should get label -1 (noise).""" + clusterer = FaceClusterer(eps=0.1, min_samples=2) + result = clusterer.cluster( + [ + ("alice-1", _unit_vec(0)), + ("alice-2", _similar_vec(0, 10, noise=0.02)), + ("noise", _unit_vec(1)), # isolated, not enough neighbours + ] + ) + labels = {fid: label for fid, label in result} + # alice-1 and alice-2 should be in the same cluster + assert labels["alice-1"] == labels["alice-2"] + assert labels["alice-1"] != -1 + # noise face should be labelled as noise + assert labels["noise"] == -1 diff --git a/ai-vision-service/face-recognition/tests/test_cropper.py b/ai-vision-service/face-recognition/tests/test_cropper.py new file mode 100644 index 00000000000..2fa3f5e5a8b --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_cropper.py @@ -0,0 +1,114 @@ +"""Tests for app.detection.cropper.""" + +from __future__ import annotations + +import base64 +import io + +from PIL import Image + +from app.detection.cropper import CROP_SIZE, generate_crop, generate_crop_from_bytes + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_jpeg_bytes(width: int = 200, height: int = 200, color: tuple[int, int, int] = (255, 0, 0)) -> bytes: + img = Image.new("RGB", (width, height), color=color) + buf = io.BytesIO() + img.save(buf, format="JPEG") + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# generate_crop +# --------------------------------------------------------------------------- + + +def test_crop_size_is_150x150(jpeg_image_path: object) -> None: + """generate_crop must return a 150x150 JPEG regardless of input size.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.0, y=0.0, width=0.5, height=0.5) + + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) + + +def test_crop_returns_base64_string(jpeg_image_path: object) -> None: + """Output must be a valid base64-encoded ASCII string.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.1, y=0.1, width=0.5, height=0.5) + assert isinstance(result, str) + # Must be valid base64 + base64.b64decode(result) + + +def test_crop_is_jpeg(jpeg_image_path: object) -> None: + """Decoded bytes must be a JPEG image.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.0, y=0.0, width=1.0, height=1.0) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.format == "JPEG" + + +def test_crop_clamps_out_of_bounds_bbox(jpeg_image_path: object) -> None: + """Bounding boxes that extend beyond image edges must be clamped.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + # x + width > 1.0 + result = generate_crop(Path(str(path)), x=0.8, y=0.8, width=0.5, height=0.5) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) + + +def test_crop_full_image_bbox(jpeg_image_path: object) -> None: + """Cropping the full image (0,0,1,1) must succeed.""" + from pathlib import Path + + path = jpeg_image_path # type: ignore[assignment] + result = generate_crop(Path(str(path)), x=0.0, y=0.0, width=1.0, height=1.0) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) + + +# --------------------------------------------------------------------------- +# generate_crop_from_bytes +# --------------------------------------------------------------------------- + + +def test_crop_from_bytes_matches_file_version(tmp_path: object) -> None: + """generate_crop_from_bytes must produce the same output as generate_crop.""" + from pathlib import Path + + jpeg_bytes = _make_jpeg_bytes(200, 200, (0, 128, 255)) + + # Write to disk + img_path = Path(str(tmp_path)) / "img.jpg" + img_path.write_bytes(jpeg_bytes) + + result_file = generate_crop(img_path, x=0.1, y=0.1, width=0.5, height=0.5) + result_bytes = generate_crop_from_bytes(jpeg_bytes, x=0.1, y=0.1, width=0.5, height=0.5) + + # Sizes should match (exact byte equality may differ due to JPEG compression) + img_a = Image.open(io.BytesIO(base64.b64decode(result_file))) + img_b = Image.open(io.BytesIO(base64.b64decode(result_bytes))) + assert img_a.size == img_b.size + + +def test_crop_from_bytes_150x150(jpeg_image_bytes: bytes) -> None: + result = generate_crop_from_bytes(jpeg_image_bytes, x=0.0, y=0.0, width=0.5, height=0.5) + decoded = base64.b64decode(result) + img = Image.open(io.BytesIO(decoded)) + assert img.size == (CROP_SIZE, CROP_SIZE) diff --git a/ai-vision-service/face-recognition/tests/test_detection.py b/ai-vision-service/face-recognition/tests/test_detection.py new file mode 100644 index 00000000000..487e124de36 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_detection.py @@ -0,0 +1,180 @@ +"""Tests for app.detection.detector.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from app.detection.detector import DetectedFace, FaceDetector + +if TYPE_CHECKING: + from pathlib import Path + +# --------------------------------------------------------------------------- +# FaceDetector.load() +# --------------------------------------------------------------------------- + + +def test_load_initialises_app() -> None: + """load() should call FaceAnalysis.prepare() and set _app.""" + mock_analysis = MagicMock() + mock_app = MagicMock() + mock_analysis.return_value = mock_app + + with patch("app.detection.detector.FaceDetector.load") as mock_load: + detector = FaceDetector(model_name="buffalo_l") + assert not detector.is_loaded + mock_load.side_effect = lambda: setattr(detector, "_app", mock_app) + detector.load() + assert detector.is_loaded + + +def test_load_is_idempotent() -> None: + """Calling load() twice should not re-initialise the model.""" + detector = FaceDetector() + + call_count = 0 + + def fake_load() -> None: + nonlocal call_count + call_count += 1 + detector._app = MagicMock() # type: ignore[attr-defined] + + with patch.object(detector, "load", side_effect=fake_load): + detector.load() + detector.load() + + assert call_count == 2 # load() called twice but internal guard handled separately + + +def test_is_loaded_false_before_load() -> None: + detector = FaceDetector() + assert not detector.is_loaded + + +# --------------------------------------------------------------------------- +# FaceDetector.detect() - detection results +# --------------------------------------------------------------------------- + + +def _make_mock_face( + bbox: list[float], + det_score: float, + embedding: list[float], +) -> Any: + face = MagicMock() + face.det_score = det_score + face.bbox = bbox + face.embedding = embedding + return face + + +def _build_detector_with_faces(faces: list[Any], *, blur_threshold: float = 0.0) -> FaceDetector: + """Return a detector whose internal InsightFace app returns ``faces``.""" + detector = FaceDetector(detection_threshold=0.5, blur_threshold=blur_threshold) + mock_app = MagicMock() + mock_app.get.return_value = faces + detector._app = mock_app # type: ignore[attr-defined] + return detector + + +def test_detect_returns_normalised_bbox(jpeg_image_path: Path) -> None: + """Bounding box coordinates must be in [0, 1].""" + # 100x100 image; face occupies left half + mock_face = _make_mock_face( + bbox=[0.0, 0.0, 50.0, 50.0], + det_score=0.99, + embedding=[0.0] * 512, + ) + detector = _build_detector_with_faces([mock_face]) + + # cv2 is lazily imported inside detect(); call _detect_array directly + # with a real numpy array so the blur filter can process it. + fake_img = np.zeros((100, 100, 3), dtype=np.uint8) + results = detector._detect_array(fake_img) + + assert len(results) == 1 + face = results[0] + assert 0.0 <= face.x <= 1.0 + assert 0.0 <= face.y <= 1.0 + assert 0.0 <= face.width <= 1.0 + assert 0.0 <= face.height <= 1.0 + + +def test_detect_filters_low_confidence() -> None: + """Faces below detection_threshold must be excluded.""" + mock_face = _make_mock_face( + bbox=[0.0, 0.0, 50.0, 50.0], + det_score=0.3, # below default threshold of 0.5 + embedding=[0.0] * 512, + ) + detector = _build_detector_with_faces([mock_face]) + fake_img = MagicMock() + fake_img.shape = (100, 100, 3) + results = detector._detect_array(fake_img) + assert results == [] + + +def test_detect_sorts_by_confidence_descending() -> None: + """Output must be sorted highest confidence first.""" + faces = [ + _make_mock_face([0, 0, 10, 10], 0.6, [0.0] * 512), + _make_mock_face([10, 10, 20, 20], 0.9, [0.0] * 512), + _make_mock_face([20, 20, 30, 30], 0.75, [0.0] * 512), + ] + detector = _build_detector_with_faces(faces) + fake_img = np.zeros((100, 100, 3), dtype=np.uint8) + results = detector._detect_array(fake_img) + confidences = [f.confidence for f in results] + assert confidences == sorted(confidences, reverse=True) + + +def test_detect_returns_full_embedding() -> None: + """Each DetectedFace must include a 512-element embedding list.""" + embedding = list(range(512)) + mock_face = _make_mock_face([0, 0, 50, 50], 0.99, embedding) + detector = _build_detector_with_faces([mock_face]) + fake_img = np.zeros((100, 100, 3), dtype=np.uint8) + results = detector._detect_array(fake_img) + assert len(results) == 1 + assert len(results[0].embedding) == 512 + assert results[0].embedding == [float(v) for v in embedding] + + +def test_detect_raises_without_load(jpeg_image_path: Path) -> None: + """detect() must raise RuntimeError if load() has not been called.""" + detector = FaceDetector() + with ( + pytest.raises(RuntimeError, match="not loaded"), + patch("cv2.imread", return_value=MagicMock(shape=(100, 100, 3))), + ): + detector.detect(jpeg_image_path) + + +def test_detect_raises_on_unreadable_file(tmp_path: Path) -> None: + """detect() must raise ValueError when OpenCV cannot read the image.""" + detector = FaceDetector() + detector._app = MagicMock() # type: ignore[attr-defined] + + with patch("cv2.imread", return_value=None), pytest.raises(ValueError, match="Cannot read image"): + detector.detect(tmp_path / "nonexistent.jpg") + + +# --------------------------------------------------------------------------- +# DetectedFace dataclass +# --------------------------------------------------------------------------- + + +def test_detected_face_fields() -> None: + face = DetectedFace(x=0.1, y=0.2, width=0.3, height=0.4, confidence=0.95) + assert face.x == 0.1 + assert face.embedding == [] + + +def test_detected_face_with_embedding() -> None: + emb = [0.5] * 512 + face = DetectedFace(x=0.0, y=0.0, width=1.0, height=1.0, confidence=0.8, embedding=emb) + assert face.embedding == emb diff --git a/ai-vision-service/face-recognition/tests/test_embeddings.py b/ai-vision-service/face-recognition/tests/test_embeddings.py new file mode 100644 index 00000000000..438b7b7d157 --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_embeddings.py @@ -0,0 +1,125 @@ +"""Tests for the SQLite embedding store.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import pytest + +from app.embeddings.sqlite_store import SQLiteEmbeddingStore + +if TYPE_CHECKING: + from pathlib import Path + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def store(tmp_path: Path) -> SQLiteEmbeddingStore: + """Return a fresh SQLite store backed by a temp directory.""" + return SQLiteEmbeddingStore(storage_path=str(tmp_path)) + + +def _unit_vec(dim: int = 512, index: int = 0) -> list[float]: + """Return a unit vector with a 1.0 at ``index`` and 0.0 elsewhere.""" + v = [0.0] * dim + v[index] = 1.0 + return v + + +# --------------------------------------------------------------------------- +# add / count +# --------------------------------------------------------------------------- + + +def test_add_increments_count(store: SQLiteEmbeddingStore) -> None: + assert store.count() == 0 + store.add("face-1", _unit_vec(index=0)) + assert store.count() == 1 + store.add("face-2", _unit_vec(index=1)) + assert store.count() == 2 + + +def test_add_upsert_does_not_duplicate(store: SQLiteEmbeddingStore) -> None: + """Re-adding an existing lychee_face_id must not create a duplicate row.""" + store.add("face-1", _unit_vec(index=0)) + store.add("face-1", _unit_vec(index=0)) + assert store.count() == 1 + + +# --------------------------------------------------------------------------- +# delete +# --------------------------------------------------------------------------- + + +def test_delete_removes_entry(store: SQLiteEmbeddingStore) -> None: + store.add("face-1", _unit_vec(index=0)) + store.delete("face-1") + assert store.count() == 0 + + +def test_delete_unknown_id_is_noop(store: SQLiteEmbeddingStore) -> None: + """Deleting a non-existent ID must not raise.""" + store.delete("nonexistent") + assert store.count() == 0 + + +# --------------------------------------------------------------------------- +# similarity_search +# --------------------------------------------------------------------------- + + +def test_similarity_search_returns_identical_face(store: SQLiteEmbeddingStore) -> None: + """An exact match should have similarity ≈ 1.0.""" + vec = _unit_vec(index=0) + store.add("face-1", vec) + + results = store.similarity_search(vec, threshold=0.9, limit=10) + assert len(results) == 1 + lychee_id, sim = results[0] + assert lychee_id == "face-1" + assert sim == pytest.approx(1.0, abs=1e-4) + + +def test_similarity_search_excludes_below_threshold(store: SQLiteEmbeddingStore) -> None: + """Results below ``threshold`` must be excluded.""" + store.add("face-1", _unit_vec(index=0)) + + # Query with an orthogonal vector - cosine similarity = 0.0 + query = _unit_vec(index=1) + results = store.similarity_search(query, threshold=0.5, limit=10) + assert results == [] + + +def test_similarity_search_respects_limit(store: SQLiteEmbeddingStore) -> None: + """At most ``limit`` results should be returned.""" + for i in range(20): + # All vectors point in roughly the same direction → high similarity + v = [1.0 / math.sqrt(512)] * 512 + store.add(f"face-{i}", v) + + results = store.similarity_search([1.0 / math.sqrt(512)] * 512, threshold=0.0, limit=5) + assert len(results) <= 5 + + +def test_similarity_search_ordered_descending(store: SQLiteEmbeddingStore) -> None: + """Results must be ordered by descending similarity.""" + store.add("face-exact", _unit_vec(index=0)) + store.add("face-close", [0.9, 0.1] + [0.0] * 510) + + # Normalise the close vector + norm = math.sqrt(0.9**2 + 0.1**2) + close = [0.9 / norm, 0.1 / norm] + [0.0] * 510 + store.add("face-close-n", close) + + results = store.similarity_search(_unit_vec(index=0), threshold=0.0, limit=10) + sims = [r[1] for r in results] + assert sims == sorted(sims, reverse=True) + + +def test_empty_store_returns_no_results(store: SQLiteEmbeddingStore) -> None: + results = store.similarity_search(_unit_vec(), threshold=0.0, limit=10) + assert results == [] diff --git a/ai-vision-service/face-recognition/tests/test_matching.py b/ai-vision-service/face-recognition/tests/test_matching.py new file mode 100644 index 00000000000..367cd55a03f --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_matching.py @@ -0,0 +1,102 @@ +"""Tests for app.matching.matcher.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from app.api.schemas import MatchResponse +from app.detection.detector import DetectedFace, FaceDetector +from app.embeddings.store import EmbeddingStore +from app.matching.matcher import FaceMatcher + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _mock_detector(faces: list[DetectedFace]) -> FaceDetector: + m = MagicMock(spec=FaceDetector) + m.is_loaded = True + m.detect_bytes.return_value = faces + return m # type: ignore[return-value] + + +def _mock_store(matches: list[tuple[str, float]]) -> EmbeddingStore: + m = MagicMock(spec=EmbeddingStore) + m.similarity_search.return_value = matches + return m # type: ignore[return-value] + + +def _detected_face(confidence: float = 0.95) -> DetectedFace: + return DetectedFace(x=0.1, y=0.1, width=0.3, height=0.3, confidence=confidence, embedding=[0.5] * 512) + + +# --------------------------------------------------------------------------- +# FaceMatcher.match() +# --------------------------------------------------------------------------- + + +def test_match_raises_when_no_face_detected() -> None: + """match() must raise ValueError when the selfie contains no detectable face.""" + matcher = FaceMatcher( + detector=_mock_detector([]), + store=_mock_store([]), + ) + with pytest.raises(ValueError, match="No face detected"): + matcher.match(b"\xff\xd8\xff") # arbitrary bytes + + +def test_match_returns_matches_from_store() -> None: + """match() must return MatchResult items built from the store search results.""" + store_matches = [("face-abc", 0.95), ("face-def", 0.72)] + matcher = FaceMatcher( + detector=_mock_detector([_detected_face()]), + store=_mock_store(store_matches), + ) + response = matcher.match(b"fake-image-bytes") + + assert isinstance(response, MatchResponse) + assert len(response.matches) == 2 + assert response.matches[0].lychee_face_id == "face-abc" + assert response.matches[0].confidence == pytest.approx(0.95) + + +def test_match_uses_highest_confidence_face() -> None: + """When multiple faces are detected, the one with highest confidence is used.""" + low_conf = _detected_face(confidence=0.6) + high_conf = _detected_face(confidence=0.99) + # Detector returns highest-confidence first (sorted), but let's simulate both orders + detector = _mock_detector([high_conf, low_conf]) + store = _mock_store([("face-xyz", 0.88)]) + + matcher = FaceMatcher(detector=detector, store=store) + matcher.match(b"fake") + + # store.similarity_search should have been called with high_conf.embedding + store.similarity_search.assert_called_once_with(high_conf.embedding, matcher._threshold, limit=10) # type: ignore[attr-defined] + + +def test_match_passes_threshold_to_store() -> None: + """The configured threshold must be forwarded to similarity_search.""" + detector = _mock_detector([_detected_face()]) + store = _mock_store([]) + matcher = FaceMatcher(detector=detector, store=store, threshold=0.8) + matcher.match(b"fake") + + store.similarity_search.assert_called_once() # type: ignore[attr-defined] + _, _kwargs = store.similarity_search.call_args # type: ignore[attr-defined] + # threshold may be passed as positional or keyword + call_args = store.similarity_search.call_args[0] # type: ignore[attr-defined] + assert call_args[1] == 0.8 # second positional arg = threshold + + +def test_match_returns_empty_list_when_no_store_matches() -> None: + """An empty match list (no faces above threshold) must be returned cleanly.""" + matcher = FaceMatcher( + detector=_mock_detector([_detected_face()]), + store=_mock_store([]), + ) + response = matcher.match(b"fake") + assert response.matches == [] diff --git a/ai-vision-service/face-recognition/tests/test_smoke.py b/ai-vision-service/face-recognition/tests/test_smoke.py new file mode 100644 index 00000000000..92672cef83c --- /dev/null +++ b/ai-vision-service/face-recognition/tests/test_smoke.py @@ -0,0 +1,112 @@ +"""End-to-end smoke test (requires Docker and a running service stack). + +This test is skipped by default. To run it: + + SMOKE_TEST=1 uv run pytest tests/test_smoke.py -v + +The test starts the service via docker-compose, waits for the health check to +pass, sends a mock detect request, and asserts the callback is received by a +mock Lychee HTTP server running in a background thread. + +Prerequisites: + - Docker and docker-compose available in PATH + - ``docker-compose.minimal.yaml`` with the ``ai-vision`` service block + - ``VISION_FACE_API_KEY`` env var set (or the default is used for local testing) +""" + +from __future__ import annotations + +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import ClassVar + +import httpx +import pytest + +SMOKE = os.getenv("SMOKE_TEST", "").lower() in ("1", "true", "yes") +pytestmark = pytest.mark.skipif(not SMOKE, reason="SMOKE_TEST env var not set") + +SERVICE_URL = os.getenv("AI_VISION_URL", "http://localhost:8123") +API_KEY = os.getenv("VISION_FACE_API_KEY", "smoke-test-key") +MOCK_LYCHEE_PORT = 19876 + + +# --------------------------------------------------------------------------- +# Mock Lychee callback server +# --------------------------------------------------------------------------- + + +class _CallbackHandler(BaseHTTPRequestHandler): + received: ClassVar[list[bytes]] = [] + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + _CallbackHandler.received.append(body) + self.send_response(200) + self.end_headers() + self.wfile.write(b'{"faces": []}') + + def log_message(self, *args: object) -> None: # suppress request log noise + pass + + +# --------------------------------------------------------------------------- +# Smoke tests +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module", autouse=True) +def mock_lychee_server() -> None: + """Start a mock Lychee callback receiver before smoke tests.""" + server = HTTPServer(("0.0.0.0", MOCK_LYCHEE_PORT), _CallbackHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield # type: ignore[misc] + server.shutdown() + + +def _wait_for_health(timeout: int = 60) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + r = httpx.get(f"{SERVICE_URL}/health", timeout=3.0) + if r.status_code == 200 and r.json().get("model_loaded"): + return + except Exception: + pass + time.sleep(2) + pytest.fail(f"Service did not become healthy within {timeout}s") + + +def test_health_endpoint_responds() -> None: + """GET /health must return 200 with model_loaded: true.""" + _wait_for_health() + r = httpx.get(f"{SERVICE_URL}/health", timeout=10.0) + assert r.status_code == 200 + assert r.json()["model_loaded"] is True + assert r.json()["status"] == "ok" + + +def test_detect_returns_202() -> None: + """POST /detect must return 202 for a synthetic request.""" + _wait_for_health() + payload = {"photo_id": "smoke-photo-1", "photo_path": "/data/photos/smoke_placeholder.jpg"} + r = httpx.post( + f"{SERVICE_URL}/detect", + json=payload, + headers={"X-API-Key": API_KEY}, + timeout=10.0, + ) + # 202 or 400 (file not found) are both valid - what matters is the service responds + assert r.status_code in (202, 400) + + +def test_health_returns_embedding_count_as_int() -> None: + """embedding_count in health response must be a non-negative integer.""" + _wait_for_health() + r = httpx.get(f"{SERVICE_URL}/health", timeout=10.0) + assert isinstance(r.json()["embedding_count"], int) + assert r.json()["embedding_count"] >= 0 diff --git a/ai-vision-service/face-recognition/uv.lock b/ai-vision-service/face-recognition/uv.lock new file mode 100644 index 00000000000..8f6a4c6950d --- /dev/null +++ b/ai-vision-service/face-recognition/uv.lock @@ -0,0 +1,1776 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "platform_machine != 's390x'", + "platform_machine == 's390x'", +] + +[[package]] +name = "ai-vision-service" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "insightface" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "opencv-python-headless" }, + { name = "pillow" }, + { name = "psycopg2-binary" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "scikit-learn" }, + { name = "sqlite-vec" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +gpu = [ + { name = "onnxruntime-gpu" }, +] + +[package.dev-dependencies] +dev = [ + { name = "anyio" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.28" }, + { name = "insightface", specifier = ">=0.7" }, + { name = "numpy", specifier = ">=2.2" }, + { name = "onnxruntime", specifier = ">=1.20" }, + { name = "onnxruntime-gpu", marker = "extra == 'gpu'", specifier = ">=1.20" }, + { name = "opencv-python-headless", specifier = ">=4.10" }, + { name = "pillow", specifier = ">=11.0" }, + { name = "psycopg2-binary", specifier = ">=2.9" }, + { name = "pydantic", specifier = ">=2.10" }, + { name = "pydantic-settings", specifier = ">=2.7" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "scikit-learn", specifier = ">=1.6" }, + { name = "sqlite-vec", specifier = ">=0.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, +] +provides-extras = ["gpu"] + +[package.metadata.requires-dev] +dev = [ + { name = "anyio", specifier = ">=4.7" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.25" }, + { name = "pytest-cov", specifier = ">=6.0" }, + { name = "respx", specifier = ">=0.21" }, + { name = "ruff", specifier = ">=0.9" }, + { name = "ty", specifier = ">=0.0.1a10" }, +] + +[[package]] +name = "albucore" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "simsimd" }, + { name = "stringzilla" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/69/d4cbcf2a5768bf91cd14ffef783520458431e5d2b22fbc08418d3ba09a88/albucore-0.0.24.tar.gz", hash = "sha256:f2cab5431fadf94abf87fd0c89d9f59046e49fe5de34afea8f89bc8390253746", size = 16981, upload-time = "2025-03-09T18:46:51.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/e2/91f145e1f32428e9e1f21f46a7022ffe63d11f549ee55c3b9265ff5207fc/albucore-0.0.24-py3-none-any.whl", hash = "sha256:adef6e434e50e22c2ee127b7a3e71f2e35fa088bcf54431e18970b62d97d0005", size = 15372, upload-time = "2025-03-09T18:46:50.177Z" }, +] + +[[package]] +name = "albumentations" +version = "2.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "albucore" }, + { name = "numpy" }, + { name = "opencv-python-headless" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/f4/85eb56c3217b53bcfc2d12e840a0b18ca60902086321cafa5a730f9c0470/albumentations-2.0.8.tar.gz", hash = "sha256:4da95e658e490de3c34af8fcdffed09e36aa8a4edd06ca9f9e7e3ea0b0b16856", size = 354460, upload-time = "2025-05-27T21:23:17.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/64/013409c451a44b61310fb757af4527f3de57fc98a00f40448de28b864290/albumentations-2.0.8-py3-none-any.whl", hash = "sha256:c4c4259aaf04a7386ad85c7fdcb73c6c7146ca3057446b745cc035805acb1017", size = 369423, upload-time = "2025-05-27T21:23:15.609Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "cython" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/85/7574c9cd44b69a27210444b6650f6477f56c75fee1b70d7672d3e4166167/cython-3.2.4.tar.gz", hash = "sha256:84226ecd313b233da27dc2eb3601b4f222b8209c3a7216d8733b031da1dc64e6", size = 3280291, upload-time = "2026-01-04T14:14:14.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/b5/1cfca43b7d20a0fdb1eac67313d6bb6b18d18897f82dd0f17436bdd2ba7f/cython-3.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:28e8075087a59756f2d059273184b8b639fe0f16cf17470bd91c39921bc154e0", size = 2960506, upload-time = "2026-01-04T14:15:16.733Z" }, + { url = "https://files.pythonhosted.org/packages/71/bb/8f28c39c342621047fea349a82fac712a5e2b37546d2f737bbde48d5143d/cython-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03893c88299a2c868bb741ba6513357acd104e7c42265809fd58dce1456a36fc", size = 3213148, upload-time = "2026-01-04T14:15:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d2/16fa02f129ed2b627e88d9d9ebd5ade3eeb66392ae5ba85b259d2d52b047/cython-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f81eda419b5ada7b197bbc3c5f4494090e3884521ffd75a3876c93fbf66c9ca8", size = 3375764, upload-time = "2026-01-04T14:15:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/91/3f/deb8f023a5c10c0649eb81332a58c180fad27c7533bb4aae138b5bc34d92/cython-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:83266c356c13c68ffe658b4905279c993d8a5337bb0160fa90c8a3e297ea9a2e", size = 2754238, upload-time = "2026-01-04T14:15:23.001Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d7/3bda3efce0c5c6ce79cc21285dbe6f60369c20364e112f5a506ee8a1b067/cython-3.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4b4fd5332ab093131fa6172e8362f16adef3eac3179fd24bbdc392531cb82fa", size = 2971496, upload-time = "2026-01-04T14:15:25.038Z" }, + { url = "https://files.pythonhosted.org/packages/89/ed/1021ffc80b9c4720b7ba869aea8422c82c84245ef117ebe47a556bdc00c3/cython-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3b5ac54e95f034bc7fb07313996d27cbf71abc17b229b186c1540942d2dc28e", size = 3256146, upload-time = "2026-01-04T14:15:26.741Z" }, + { url = "https://files.pythonhosted.org/packages/0c/51/ca221ec7e94b3c5dc4138dcdcbd41178df1729c1e88c5dfb25f9d30ba3da/cython-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f43be4eaa6afd58ce20d970bb1657a3627c44e1760630b82aa256ba74b4acb", size = 3383458, upload-time = "2026-01-04T14:15:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/1388fc0243240cd54994bb74f26aaaf3b2e22f89d3a2cf8da06d75d46ca2/cython-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:983f9d2bb8a896e16fa68f2b37866ded35fa980195eefe62f764ddc5f9f5ef8e", size = 2791241, upload-time = "2026-01-04T14:15:30.448Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/fd393f0923c82be4ec0db712fffb2ff0a7a131707b842c99bf24b549274d/cython-3.2.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:36bf3f5eb56d5281aafabecbaa6ed288bc11db87547bba4e1e52943ae6961ccf", size = 2875622, upload-time = "2026-01-04T14:15:39.749Z" }, + { url = "https://files.pythonhosted.org/packages/73/48/48530d9b9d64ec11dbe0dd3178a5fe1e0b27977c1054ecffb82be81e9b6a/cython-3.2.4-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6d5267f22b6451eb1e2e1b88f6f78a2c9c8733a6ddefd4520d3968d26b824581", size = 3210669, upload-time = "2026-01-04T14:15:41.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/91/4865fbfef1f6bb4f21d79c46104a53d1a3fa4348286237e15eafb26e0828/cython-3.2.4-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b6e58f73a69230218d5381817850ce6d0da5bb7e87eb7d528c7027cbba40b06", size = 2856835, upload-time = "2026-01-04T14:15:43.815Z" }, + { url = "https://files.pythonhosted.org/packages/fa/39/60317957dbef179572398253f29d28f75f94ab82d6d39ea3237fb6c89268/cython-3.2.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e71efb20048358a6b8ec604a0532961c50c067b5e63e345e2e359fff72feaee8", size = 2994408, upload-time = "2026-01-04T14:15:45.422Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/7c24d9292650db4abebce98abc9b49c820d40fa7c87921c0a84c32f4efe7/cython-3.2.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:28b1e363b024c4b8dcf52ff68125e635cb9cb4b0ba997d628f25e32543a71103", size = 2891478, upload-time = "2026-01-04T14:15:47.394Z" }, + { url = "https://files.pythonhosted.org/packages/86/70/03dc3c962cde9da37a93cca8360e576f904d5f9beecfc9d70b1f820d2e5f/cython-3.2.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:31a90b4a2c47bb6d56baeb926948348ec968e932c1ae2c53239164e3e8880ccf", size = 3225663, upload-time = "2026-01-04T14:15:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/b1/97/10b50c38313c37b1300325e2e53f48ea9a2c078a85c0c9572057135e31d5/cython-3.2.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e65e4773021f8dc8532010b4fbebe782c77f9a0817e93886e518c93bd6a44e9d", size = 3115628, upload-time = "2026-01-04T14:15:51.323Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b1/d6a353c9b147848122a0db370863601fdf56de2d983b5c4a6a11e6ee3cd7/cython-3.2.4-cp39-abi3-win32.whl", hash = "sha256:2b1f12c0e4798293d2754e73cd6f35fa5bbdf072bdc14bc6fc442c059ef2d290", size = 2437463, upload-time = "2026-01-04T14:15:53.787Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d8/319a1263b9c33b71343adfd407e5daffd453daef47ebc7b642820a8b68ed/cython-3.2.4-cp39-abi3-win_arm64.whl", hash = "sha256:3b8e62049afef9da931d55de82d8f46c9a147313b69d5ff6af6e9121d545ce7a", size = 2442754, upload-time = "2026-01-04T14:15:55.382Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" }, +] + +[[package]] +name = "easydict" +version = "1.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/9f/d18d6b5e19244788a6d09c14a8406376b4f4bfcc008e6d17a4f4c15362e8/easydict-1.13.tar.gz", hash = "sha256:b1135dedbc41c8010e2bc1f77ec9744c7faa42bce1a1c87416791449d6c87780", size = 6809, upload-time = "2024-03-04T12:04:41.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/ec/fa6963f1198172c2b75c9ab6ecefb3045991f92f75f5eb41b6621b198123/easydict-1.13-py3-none-any.whl", hash = "sha256:6b787daf4dcaf6377b4ad9403a5cee5a86adbc0ca9a5bcf5410e9902002aeac2", size = 6804, upload-time = "2024-03-04T12:04:39.508Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "insightface" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "albumentations" }, + { name = "cython" }, + { name = "easydict" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "onnx" }, + { name = "pillow" }, + { name = "prettytable" }, + { name = "requests" }, + { name = "scikit-image" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/8d/0f4af90999ca96cf8cb846eb5ae27c5ef5b390f9c090dd19e4fa76364c13/insightface-0.7.3.tar.gz", hash = "sha256:f191f719612ebb37018f41936814500544cd0f86e6fcd676c023f354c668ddf7", size = 439490, upload-time = "2023-04-02T08:01:54.541Z" } + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + +[[package]] +name = "ml-dtypes" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/4a/c27b42ed9b1c7d13d9ba8b6905dece787d6259152f2309338aed29b2447b/ml_dtypes-0.5.4.tar.gz", hash = "sha256:8ab06a50fb9bf9666dd0fe5dfb4676fa2b0ac0f31ecff72a6c3af8e22c063453", size = 692314, upload-time = "2025-11-17T22:32:31.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/a1/4008f14bbc616cfb1ac5b39ea485f9c63031c4634ab3f4cf72e7541f816a/ml_dtypes-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c760d85a2f82e2bed75867079188c9d18dae2ee77c25a54d60e9cc79be1bc48", size = 676888, upload-time = "2025-11-17T22:31:56.907Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/dff378afc2b0d5a7d6cd9d3209b60474d9819d1189d347521e1688a60a53/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce756d3a10d0c4067172804c9cc276ba9cc0ff47af9078ad439b075d1abdc29b", size = 5036993, upload-time = "2025-11-17T22:31:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/eb/33/40cd74219417e78b97c47802037cf2d87b91973e18bb968a7da48a96ea44/ml_dtypes-0.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:533ce891ba774eabf607172254f2e7260ba5f57bdd64030c9a4fcfbd99815d0d", size = 5010956, upload-time = "2025-11-17T22:31:59.931Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8b/200088c6859d8221454825959df35b5244fa9bdf263fd0249ac5fb75e281/ml_dtypes-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:f21c9219ef48ca5ee78402d5cc831bd58ea27ce89beda894428bc67a52da5328", size = 212224, upload-time = "2025-11-17T22:32:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/dfc3775cb36367816e678f69a7843f6f03bd4e2bcd79941e01ea960a068e/ml_dtypes-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:35f29491a3e478407f7047b8a4834e4640a77d2737e0b294d049746507af5175", size = 160798, upload-time = "2025-11-17T22:32:02.864Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/e9ddb35fd1dd43b1106c20ced3f53c2e8e7fc7598c15638e9f80677f81d4/ml_dtypes-0.5.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:304ad47faa395415b9ccbcc06a0350800bc50eda70f0e45326796e27c62f18b6", size = 702083, upload-time = "2025-11-17T22:32:04.08Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/667060b0aed1aa63166b22897fdf16dca9eb704e6b4bbf86848d5a181aa7/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a0df4223b514d799b8a1629c65ddc351b3efa833ccf7f8ea0cf654a61d1e35d", size = 5354111, upload-time = "2025-11-17T22:32:05.546Z" }, + { url = "https://files.pythonhosted.org/packages/40/49/0f8c498a28c0efa5f5c95a9e374c83ec1385ca41d0e85e7cf40e5d519a21/ml_dtypes-0.5.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531eff30e4d368cb6255bc2328d070e35836aa4f282a0fb5f3a0cd7260257298", size = 5366453, upload-time = "2025-11-17T22:32:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/8c/27/12607423d0a9c6bbbcc780ad19f1f6baa2b68b18ce4bddcdc122c4c68dc9/ml_dtypes-0.5.4-cp313-cp313t-win_amd64.whl", hash = "sha256:cb73dccfc991691c444acc8c0012bee8f2470da826a92e3a20bb333b1a7894e6", size = 225612, upload-time = "2025-11-17T22:32:08.615Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/5a5929e92c72936d5b19872c5fb8fc09327c1da67b3b68c6a13139e77e20/ml_dtypes-0.5.4-cp313-cp313t-win_arm64.whl", hash = "sha256:3bbbe120b915090d9dd1375e4684dd17a20a2491ef25d640a908281da85e73f1", size = 164145, upload-time = "2025-11-17T22:32:09.782Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/1339dc6e2557a344f5ba5590872e80346f76f6cb2ac3dd16e4666e88818c/ml_dtypes-0.5.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2b857d3af6ac0d39db1de7c706e69c7f9791627209c3d6dedbfca8c7e5faec22", size = 673781, upload-time = "2025-11-17T22:32:11.364Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/067b84365c7e83bda15bba2b06c6ca250ce27b20630b1128c435fb7a09aa/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:805cef3a38f4eafae3a5bf9ebdcdb741d0bcfd9e1bd90eb54abd24f928cd2465", size = 5036145, upload-time = "2025-11-17T22:32:12.783Z" }, + { url = "https://files.pythonhosted.org/packages/c6/bb/82c7dcf38070b46172a517e2334e665c5bf374a262f99a283ea454bece7c/ml_dtypes-0.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14a4fd3228af936461db66faccef6e4f41c1d82fcc30e9f8d58a08916b1d811f", size = 5010230, upload-time = "2025-11-17T22:32:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/2bfed22d2498c468f6bcd0d9f56b033eaa19f33320389314c19ef6766413/ml_dtypes-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:8c6a2dcebd6f3903e05d51960a8058d6e131fe69f952a5397e5dbabc841b6d56", size = 221032, upload-time = "2025-11-17T22:32:15.763Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/9c912fe6ea747bb10fe2f8f54d027eb265db05dfb0c6335e3e063e74e6e8/ml_dtypes-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:5a0f68ca8fd8d16583dfa7793973feb86f2fbb56ce3966daf9c9f748f52a2049", size = 163353, upload-time = "2025-11-17T22:32:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/cd/02/48aa7d84cc30ab4ee37624a2fd98c56c02326785750cd212bc0826c2f15b/ml_dtypes-0.5.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:bfc534409c5d4b0bf945af29e5d0ab075eae9eecbb549ff8a29280db822f34f9", size = 702085, upload-time = "2025-11-17T22:32:18.175Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e7/85cb99fe80a7a5513253ec7faa88a65306be071163485e9a626fce1b6e84/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2314892cdc3fcf05e373d76d72aaa15fda9fb98625effa73c1d646f331fcecb7", size = 5355358, upload-time = "2025-11-17T22:32:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/79/2b/a826ba18d2179a56e144aef69e57fb2ab7c464ef0b2111940ee8a3a223a2/ml_dtypes-0.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d2ffd05a2575b1519dc928c0b93c06339eb67173ff53acb00724502cda231cf", size = 5366332, upload-time = "2025-11-17T22:32:21.193Z" }, + { url = "https://files.pythonhosted.org/packages/84/44/f4d18446eacb20ea11e82f133ea8f86e2bf2891785b67d9da8d0ab0ef525/ml_dtypes-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4381fe2f2452a2d7589689693d3162e876b3ddb0a832cde7a414f8e1adf7eab1", size = 236612, upload-time = "2025-11-17T22:32:22.579Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3f/3d42e9a78fe5edf792a83c074b13b9b770092a4fbf3462872f4303135f09/ml_dtypes-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:11942cbf2cf92157db91e5022633c0d9474d4dfd813a909383bd23ce828a4b7d", size = 168825, upload-time = "2025-11-17T22:32:23.766Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "onnx" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/93/942d2a0f6a70538eea042ce0445c8aefd46559ad153469986f29a743c01c/onnx-1.21.0.tar.gz", hash = "sha256:4d8b67d0aaec5864c87633188b91cc520877477ec0254eda122bef8be43cd764", size = 12074608, upload-time = "2026-03-27T21:33:36.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ae/cb644ec84c25e63575d9d8790fdcc5d1a11d67d3f62f872edb35fa38d158/onnx-1.21.0-cp312-abi3-macosx_12_0_universal2.whl", hash = "sha256:fc2635400fe39ff37ebc4e75342cc54450eadadf39c540ff132c319bf4960095", size = 17965930, upload-time = "2026-03-27T21:32:48.089Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b6/eeb5903586645ef8a49b4b7892580438741acc3df91d7a5bd0f3a59ea9cb/onnx-1.21.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9003d5206c01fa2ff4b46311566865d8e493e1a6998d4009ec6de39843f1b59b", size = 17531344, upload-time = "2026-03-27T21:32:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/a7/00/4823f06357892d1e60d6f34e7299d2ba4ed2108c487cc394f7ce85a3ff14/onnx-1.21.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9261bd580fb8548c9c37b3c6750387eb8f21ea43c63880d37b2c622e1684285", size = 17613697, upload-time = "2026-03-27T21:32:54.222Z" }, + { url = "https://files.pythonhosted.org/packages/23/1d/391f3c567ae068c8ac4f1d1316bae97c9eb45e702f05975fe0e17ad441f0/onnx-1.21.0-cp312-abi3-win32.whl", hash = "sha256:9ea4e824964082811938a9250451d89c4ec474fe42dd36c038bfa5df31993d1e", size = 16287200, upload-time = "2026-03-27T21:32:57.277Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a6/5eefbe5b40ea96de95a766bd2e0e751f35bdea2d4b951991ec9afaa69531/onnx-1.21.0-cp312-abi3-win_amd64.whl", hash = "sha256:458d91948ad9a7729a347550553b49ab6939f9af2cddf334e2116e45467dc61f", size = 16441045, upload-time = "2026-03-27T21:33:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/c4/0ed8dc037a39113d2a4d66e0005e07751c299c46b993f1ad5c2c35664c20/onnx-1.21.0-cp312-abi3-win_arm64.whl", hash = "sha256:ca14bc4842fccc3187eb538f07eabeb25a779b39388b006db4356c07403a7bbb", size = 16403134, upload-time = "2026-03-27T21:33:03.987Z" }, + { url = "https://files.pythonhosted.org/packages/f8/89/0e1a9beb536401e2f45ac88735e123f2735e12fc7b56ff6c11727e097526/onnx-1.21.0-cp313-cp313t-macosx_12_0_universal2.whl", hash = "sha256:257d1d1deb6a652913698f1e3f33ef1ca0aa69174892fe38946d4572d89dd94f", size = 17975430, upload-time = "2026-03-27T21:33:07.005Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e6dc71a7b3b317265591b20a5f71d0ff5c0d26c24e52283139dc90c66038/onnx-1.21.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cd7cb8f6459311bdb557cbf6c0ccc6d8ace11c304d1bba0a30b4a4688e245f8", size = 17537435, upload-time = "2026-03-27T21:33:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/27affcac63eaf2ef183a44fd1a1354b11da64a6c72fe6f3fdcf5571bcee5/onnx-1.21.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b58a4cfec8d9311b73dc083e4c1fa362069267881144c05139b3eba5dc3a840", size = 17617687, upload-time = "2026-03-27T21:33:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5c/ac8ed15e941593a3672ce424280b764979026317811f2e8508432bfc3429/onnx-1.21.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1a9baf882562c4cebf79589bebb7cd71a20e30b51158cac3e3bbaf27da6163bd", size = 16449402, upload-time = "2026-03-27T21:33:15.555Z" }, + { url = "https://files.pythonhosted.org/packages/0e/aa/d2231e0dcaad838217afc64c306c8152a080134d2034e247cc973d577674/onnx-1.21.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bba12181566acf49b35875838eba49536a327b2944664b17125577d230c637ad", size = 16408273, upload-time = "2026-03-27T21:33:18.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0a/8905b14694def6ad23edf1011fdd581500384062f8c4c567e114be7aa272/onnx-1.21.0-cp314-cp314t-macosx_12_0_universal2.whl", hash = "sha256:7ee9d8fd6a4874a5fa8b44bbcabea104ce752b20469b88bc50c7dcf9030779ad", size = 17975331, upload-time = "2026-03-27T21:33:21.69Z" }, + { url = "https://files.pythonhosted.org/packages/61/28/f4e401e5199d1b9c8b76c7e7ae1169e050515258e877b58fa8bb49d3bdcc/onnx-1.21.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5489f25fe461e7f32128218251a466cabbeeaf1eaa791c79daebf1a80d5a2cc9", size = 17537430, upload-time = "2026-03-27T21:33:24.547Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/5d13320eb3660d5af360ea3b43aa9c63a70c92a9b4d1ea0d34501a32fcb8/onnx-1.21.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db17fc0fec46180b6acbd1d5d8650a04e5527c02b09381da0b5b888d02a204c8", size = 17617662, upload-time = "2026-03-27T21:33:27.418Z" }, + { url = "https://files.pythonhosted.org/packages/4d/50/3eaa1878338247be021e6423696813d61e77e534dccbd15a703a144e703d/onnx-1.21.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19d9971a3e52a12968ae6c70fd0f86c349536de0b0c33922ecdbe52d1972fe60", size = 16463688, upload-time = "2026-03-27T21:33:30.229Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/38d46b43bbb525e0b6a4c2c4204cc6795d67e45687a2f7403e06d8e7053d/onnx-1.21.0-cp314-cp314t-win_arm64.whl", hash = "sha256:efba467efb316baf2a9452d892c2f982b9b758c778d23e38c7f44fa211b30bb9", size = 16423387, upload-time = "2026-03-27T21:33:33.446Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, + { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, +] + +[[package]] +name = "onnxruntime-gpu" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/4e/56d11203d7a35e7d6a5ea735f5fecb8673537038c07323e8d3090a896547/onnxruntime_gpu-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbdaa73f9055fb2a177425edbed651a1843a6239f9d5430e284f4e5f65440a33", size = 252763446, upload-time = "2026-03-17T22:04:09.515Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bc/35f3a37226d7a28c84b8b456f52237ccd39eb7111114bcf9ac340178e1ec/onnxruntime_gpu-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:6be8bf2048777c517fca33eb61e114969fa326619feaa789d8c75f24337ea762", size = 207198775, upload-time = "2026-03-17T21:58:48.768Z" }, + { url = "https://files.pythonhosted.org/packages/37/83/0c851882051b38f245f44b4a51d6232b95b8cd5d334b2c1260f2d796834f/onnxruntime_gpu-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4b348a078ced73fc577d21b83992fd2187edd10c233729c8d01b000b8543525", size = 252774594, upload-time = "2026-03-17T22:04:24.957Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5b/82b27f766b64f97c9a98b772dc07b608e900bd2faafdfa176b86d20be7f8/onnxruntime_gpu-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af9dd7ef92d94c75e5523cf070e180f3d8cdbb2fc007dcea97ba71b03e3b96d6", size = 252765395, upload-time = "2026-03-17T22:04:37.305Z" }, + { url = "https://files.pythonhosted.org/packages/5d/95/fa8c48e03790c979167d08164b34a8442c7074bca4c7253b4455497025de/onnxruntime_gpu-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:4dde3d2f1039060c42b12fd446fc0da5b836cc65dceb4020ca60a04cffa1d90d", size = 209597109, upload-time = "2026-03-17T21:58:58.136Z" }, + { url = "https://files.pythonhosted.org/packages/1a/98/7707edefcecf69d6c45b83a83f13ac58257017b4eaf58772668d302f849f/onnxruntime_gpu-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:097c6f53e99ee35f21d0fdba76ca283b92465a0e364c6f0209cb9653c424e2a4", size = 252776951, upload-time = "2026-03-17T22:04:49.715Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prettytable" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "simsimd" +version = "6.5.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/8c/070a179eb509b689509dacbd0bc81aa2e36614aff2c8aa6dc6c440886206/simsimd-6.5.16.tar.gz", hash = "sha256:0a005c6e2dacec83f235a747f7dbecca46b5d4d1e183ecc1929ca556ee7d7564", size = 187216, upload-time = "2026-03-07T14:36:23.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/f2/e1dedb4b3644c76467c84ffb57fc6e7784f46f312c34be9d6b52144e3d90/simsimd-6.5.16-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d0af914ab13741744ea1bd3521e719226633f2ab082dc5b07790c61685d88558", size = 105157, upload-time = "2026-03-07T14:35:04.455Z" }, + { url = "https://files.pythonhosted.org/packages/46/21/a52af2040ad608cc236583ada58b0bfa5ffbfdc83b1d3565f4793f28cade/simsimd-6.5.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:683f758d0261b3d8790f8c9fc63fdc64b7af4db66b59ba7a31556a755cb38df7", size = 94604, upload-time = "2026-03-07T14:35:05.982Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/c6c7f66368204f0aa544aa074fa84b42a4146cf9e4bc79c3896c155d9abc/simsimd-6.5.16-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc1e29d8fed1c2b89338062fa17283b78181c84d2b024cc9bf7ed75402810bfc", size = 385102, upload-time = "2026-03-07T14:35:07.32Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f2/6d84388c6e0f0637321149bc84bbbfa54a12f65f29bc6a007dd1403bf6f7/simsimd-6.5.16-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec7e92323c820935475bc9ec84938eecc9d9bc625055ff057a6d0dcfffb7eb2a", size = 583601, upload-time = "2026-03-07T14:35:08.799Z" }, + { url = "https://files.pythonhosted.org/packages/14/53/26bf42b6f8ec1f5680d91e95e276e49662bc1b8e0522c4861a0c3349b7ba/simsimd-6.5.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5a4be386421726204f70e9f8601dc8818fc2df0032ef6dcd218cdf224a9fce18", size = 421445, upload-time = "2026-03-07T14:35:10.298Z" }, + { url = "https://files.pythonhosted.org/packages/67/05/31b5247c0e17cd82482fd1724881d49ce442ad6affb2776efae8f9cc4835/simsimd-6.5.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe922886957645e041618fddf242a89f5f7ded0c4bee13dc6537f749ccf75ba2", size = 619612, upload-time = "2026-03-07T14:35:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/5d/54/dbc23d585a57c9b0e71ab10705c4121ce91a807df374d433dd86fb438caa/simsimd-6.5.16-cp313-cp313-win_amd64.whl", hash = "sha256:fe7a0fa49b09651cc1721f5928fa68665f4957c492937241bbdd6ed040dc4a5d", size = 87460, upload-time = "2026-03-07T14:35:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3c/62a41c182ab6f7abfbfe8941fa12d08b8235b4498e988e5d1f29ac21504f/simsimd-6.5.16-cp313-cp313-win_arm64.whl", hash = "sha256:3fc01992b9d3be84d4826c0d9f8a894668ad931285c09f74bdbe61a5400c9f4d", size = 62922, upload-time = "2026-03-07T14:35:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/df/df/6a1b62074968bbd2976611ac9f89fa60bde2c0c3171f1eb303314bd2bb40/simsimd-6.5.16-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:22624893c86cb9f07968a7e471ed81b2e59f68ba4941cea69ee7418b5cc6fe8e", size = 105335, upload-time = "2026-03-07T14:35:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/09/4f/43bf19becc155e5efdd31dc220c1bd34f866172739a7a081a8bfa2cae840/simsimd-6.5.16-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10d8b32ecee86a86fe30abb35a7c47c1d76756838355bc4377b73bdc69d16ed4", size = 94782, upload-time = "2026-03-07T14:35:18.233Z" }, + { url = "https://files.pythonhosted.org/packages/27/91/c31085edffdc81343f81b937fb2930cd0e105cfab1b9b97845c45b3621c3/simsimd-6.5.16-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b5a632299ee145fa2eab53906922d1596ee63f5a182e3741cde9b18745afe68", size = 387117, upload-time = "2026-03-07T14:35:19.573Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a6/faaf1633cf9d3fc5ebe46d2f145f42257accc6bd25420d722702b6b5adfb/simsimd-6.5.16-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:40a7e14e02acebd0cdadc88c3eeb262c6cbff550a10d4bce2c7771756cf68658", size = 275340, upload-time = "2026-03-07T15:13:14.926Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c8/3c3fa982272ab7a5943ceacc04fd64a38d408fce2cd45e7890eb932e92d1/simsimd-6.5.16-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4b878a28a338c30768cb401f4fbb79bd5b911d95ca024717077f1c57746ad78", size = 297257, upload-time = "2026-03-07T15:13:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/81e83b57992f1ae1bb3fa3d55cd1c4a5bd5dafcec6bd44273eb59c8f8f79/simsimd-6.5.16-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:639bb66dbb15da8727267dc7b7fbf7cc59c18ccef901dd83cdff4f12651f0244", size = 286880, upload-time = "2026-03-07T15:13:19.428Z" }, + { url = "https://files.pythonhosted.org/packages/5e/96/de52bf9ffff59c71b9bc672d7a539c431d81a17d909c4ee734f7731b51d2/simsimd-6.5.16-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:999acb24a43c619af6217b513536ae28bfe23c8fa170a4120a3cca7fdd22acff", size = 585133, upload-time = "2026-03-07T14:35:21.53Z" }, + { url = "https://files.pythonhosted.org/packages/84/e8/190aead5370bc3e0bd0f5fbd938a27cac4678dd903e81ff16acae7d7c6e4/simsimd-6.5.16-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8524c7fd12f7ef9b97e824c65db4e89919b7cc8d530780119b3417ce8643a3c2", size = 422963, upload-time = "2026-03-07T14:35:23.137Z" }, + { url = "https://files.pythonhosted.org/packages/c0/26/d6ecb102a16f01ea22e98bbf8da37b9a8cb4fb38459b939367afb401f1c4/simsimd-6.5.16-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:973460e647b3f769e714caa40b64f56dcf95a4afca98cdd19e2c3c1c9527e438", size = 320199, upload-time = "2026-03-07T15:13:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/07/08/920d1619df54ed2c377dbfb10e0a561e27731091995ba1093b600ed3f00c/simsimd-6.5.16-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:141437e4d727872ab50fe3b19098816aee23b8c3519ee04c9831ef0326e444e1", size = 340041, upload-time = "2026-03-07T15:13:23.91Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3e/995e875eca129b1acb35e4824f1f4fab30b8393da80d51883552e2edd60f/simsimd-6.5.16-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3daee137ffc2dd8bbe64b7f0f95ca2b2302b2985c35a6a7be61626052aa74e5d", size = 317465, upload-time = "2026-03-07T15:13:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ce/892865784240c167624bf55f835ff74d52e24c7d7f1b9aa79f77358397ac/simsimd-6.5.16-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:03f4d0a8aff48160e3b0acb44ac5525a39d26348db907d6d5ef516369b309973", size = 620749, upload-time = "2026-03-07T14:35:25.077Z" }, + { url = "https://files.pythonhosted.org/packages/54/0d/b74a391fefe7d349230b58b1d0fe6d401d1625553b5375a99608f9d228a9/simsimd-6.5.16-cp313-cp313t-win_amd64.whl", hash = "sha256:01ef2ff8cf99fc3a8e23fb2cadc06b6aa4df9b5e6d001b184d42cf403b1cdc16", size = 87630, upload-time = "2026-03-07T14:35:26.598Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ac/004dc381de9ac6634c785d0284dba8d1f12018584ddd992c09d9f85454b9/simsimd-6.5.16-cp313-cp313t-win_arm64.whl", hash = "sha256:a152c559298bae402ed8205b604e5b0418a2ce8a61a6a87f14973e53b68d5f6a", size = 63126, upload-time = "2026-03-07T14:35:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/6b/5a/b70d670c67ca3d0284b4a52e32d65eb9767df51c0ff5b968db6a2bdc406c/simsimd-6.5.16-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c70924ce14c7ed1663ff131f34bdf3987042f569b41a4ed756a1ad65109de760", size = 105215, upload-time = "2026-03-07T14:35:29.677Z" }, + { url = "https://files.pythonhosted.org/packages/cd/16/59a7d17719a49d453d35a21d2fc40bd7915f78046f82b3325f1f5629505a/simsimd-6.5.16-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfa1237885074a8e8aba7c203d82e189b84760ffa946fb53e82ece762f40f36c", size = 94618, upload-time = "2026-03-07T14:35:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/02/75/8cb99c018b1c68b5048e19df9d4552d5f41f0512f2e32fdd6a5e58a5b2d1/simsimd-6.5.16-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ecf8eb87e39a72e23126bf7ffa1a454830ec2daddd00ac89cef96aefce788a7", size = 385337, upload-time = "2026-03-07T14:35:32.863Z" }, + { url = "https://files.pythonhosted.org/packages/79/48/0fd0017b306422d950758e8077e00295d5d9dc2add4680c0aad437774128/simsimd-6.5.16-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0029256c39bafc3930884b47280628ff84a8eda3b7b55e64465f0e051df93cb8", size = 583769, upload-time = "2026-03-07T14:35:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/03/4e/803bffa17b5d52bd545b906f28d947630f271d6a4dc53324d5177464babe/simsimd-6.5.16-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9afa80898b89cdb65317ca6f36efedb3320a000205a82b70dd2ea82872482d08", size = 421581, upload-time = "2026-03-07T14:35:36.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/93bbf9c1a6b554b4cf21b32f436fc0de082fe929c4c459d295292ee8bcce/simsimd-6.5.16-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc6b72bf5a62afa66a9b51f6a01d751d8f217c9f7d4b1ea094e495c3dce87c33", size = 619710, upload-time = "2026-03-07T14:35:38.338Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/207158749eb6ad8576a1b3cd4e80b7f1e2a0fc59fb2b0730f8df43b3d4a9/simsimd-6.5.16-cp314-cp314-win_amd64.whl", hash = "sha256:96fdb750432ad6478177fb80612b3aea2da002dff613f1fddd19334da9b7f25e", size = 90117, upload-time = "2026-03-07T14:35:40.495Z" }, + { url = "https://files.pythonhosted.org/packages/66/67/38ab856761cc62fbb92b328350a6652f87b27ab2ca1d49fa934aaeca0d3c/simsimd-6.5.16-cp314-cp314-win_arm64.whl", hash = "sha256:2e3981bfa3f09fa9fac845037df7c3a684e0538ff297d3b2ccd26a2eed243f80", size = 64908, upload-time = "2026-03-07T14:35:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/df617f9e5605b48b75d921b5361c88475879b95a43dd3f2b77fb4659382a/simsimd-6.5.16-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:864a0497c8d4bdc6948bedb016836ba777d14a93300c3735c6e84444241cd66e", size = 105371, upload-time = "2026-03-07T14:35:43.552Z" }, + { url = "https://files.pythonhosted.org/packages/45/3f/e0b8064146919d40436503032f331fc92fbd3d8e5b29ca01c40a675432cf/simsimd-6.5.16-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:492b86704d942fa3ec627523ba7f40e87203e4222d498aa6fc880a865e13fa76", size = 94790, upload-time = "2026-03-07T14:35:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/74/6f/b3811e96e6582e4f04793b688eb1f85e2a74722f00089dc5c7932023d523/simsimd-6.5.16-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c4e0e257e191c2e1ac94737901ec3771b076f7b9c032b620c0bfb747ecefcd9", size = 387243, upload-time = "2026-03-07T14:35:46.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/46b52cd8df732e73799adb91af16e2bc872e597349b01f456df3008d4dd7/simsimd-6.5.16-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ed0eec1d7d5124bc86256a8d7ac81b1c6363149e1f1cc957007418da04e8ed", size = 585270, upload-time = "2026-03-07T14:35:48.885Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/92c7dc6b6478032cde9d65f997e8135f5e178c455b8585877b3a9f996bf7/simsimd-6.5.16-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b331c7c2222bc03139e0821c076103ea50f9fab5750571b4cd1e53c2ba3cb0d6", size = 423066, upload-time = "2026-03-07T14:35:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/4d/03/ad761cc350e0f30cd52f798e39434ce68bd09a741e931f4458ddafd0d099/simsimd-6.5.16-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c51b74b8f9b096ddd98beea66e18751ad079c398600d8c877a5d228a1f23d20", size = 620824, upload-time = "2026-03-07T14:35:52.638Z" }, + { url = "https://files.pythonhosted.org/packages/bf/aa/b059b409ae311d4d5e936c07c506c62d5f547597933822fe8c54d32e276b/simsimd-6.5.16-cp314-cp314t-win_amd64.whl", hash = "sha256:4aedebecab2c776177c2db2cdd2f311892d9b1b71bcf66d889539ab1e22ad9a6", size = 90323, upload-time = "2026-03-07T14:35:54.438Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/2d0a48ef00dd495b5ded82a476ec4300ae3f67496cbd7c7fe2777de89a3c/simsimd-6.5.16-cp314-cp314t-win_arm64.whl", hash = "sha256:d63af5fbd32b0346ef949794451b6c1ec58a66139d3ca22177f93cf7c4be7877", size = 65109, upload-time = "2026-03-07T14:35:55.863Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/85/9fad0045d8e7c8df3e0fa5a56c630e8e15ad6e5ca2e6106fceb666aa6638/sqlite_vec-0.1.9-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:1b62a7f0a060d9475575d4e599bbf94a13d85af896bc1ce86ee80d1b5b48e5fb", size = 131171, upload-time = "2026-03-31T08:02:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/3677e0cd2f92e5ebc43cd29fbf565b75582bff1ccfa0b8327c7508e1084f/sqlite_vec-0.1.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d52e30513bae4cc9778ddbf6145610434081be4c3afe57cd877893bad9f6b6c", size = 165434, upload-time = "2026-03-31T08:02:32.712Z" }, + { url = "https://files.pythonhosted.org/packages/00/d4/f2b936d3bdc38eadcbd2a87875815db36430fab0363182ba5d12cd8e0b51/sqlite_vec-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e921e592f24a5f9a18f590b6ddd530eb637e2d474e3b1972f9bbeb773aa3cb9", size = 160076, upload-time = "2026-03-31T08:02:33.796Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ad/6afd073b0f817b3e03f9e37ad626ae341805891f23c74b5292818f49ac63/sqlite_vec-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:1515727990b49e79bcaf75fdee2ffc7d461f8b66905013231251f1c8938e7786", size = 163388, upload-time = "2026-03-31T08:02:34.888Z" }, + { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "stringzilla" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/68/475518f6f4af8273ecd619a5d37d715d36908973f9970faf21571a296821/stringzilla-4.6.0.tar.gz", hash = "sha256:640c0fb5b6a2ad77b7721bff98f00a3c524ca60dc202f552e486831a751d4bbd", size = 646335, upload-time = "2025-12-26T23:44:43.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/39/ee92df4c6ffc19d763824c061e47b358e1ab8e0724f35d0a41d17dd4e850/stringzilla-4.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0123f724dc63c7e7dbb6eda5f821593effc5fda889ba392e0f7c676924d1131b", size = 212271, upload-time = "2025-12-26T23:43:30.676Z" }, + { url = "https://files.pythonhosted.org/packages/b6/21/641914f44f195fb718e1ebf111f0f70150cf5e4272b3577d6b832fd085f6/stringzilla-4.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41ec1735fdeaf4e276be0a531d4dd7ad4cb1e7071fd8729c2ca579a5ea49f92a", size = 199360, upload-time = "2025-12-26T23:43:31.993Z" }, + { url = "https://files.pythonhosted.org/packages/8e/20/561b7199cfeacf6ef68d26a8846a0e6f521e96e4be4f9d20098d80b6314d/stringzilla-4.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1105de1b97b7b8f2c8e4dda0cb370cef882fdb5df8f945c141bc022ca3ba65f", size = 689260, upload-time = "2025-12-26T23:43:33.58Z" }, + { url = "https://files.pythonhosted.org/packages/ba/71/70bbbf4e59f4dd2e097f36a4e96482a546a0f081721068e32a7d02f60014/stringzilla-4.6.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e5f8d1d72e23981964aec25a1eb41a9c42814d1a4e90467c0e27fb1f4c2fe948", size = 657134, upload-time = "2025-12-26T23:43:35.103Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/1e98779e66990d15ad1bb025f24ddef736712333b173926c06977159613c/stringzilla-4.6.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac8fc7c55b612a52855143eff0981c11c80d0a64472bf77b68339bdea01737c0", size = 640353, upload-time = "2025-12-26T23:43:36.509Z" }, + { url = "https://files.pythonhosted.org/packages/30/44/e73fd1c47f23ee7513bf86451464db2eda3d8835e9be408f9dd9ca06a0af/stringzilla-4.6.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:899974a4e8231abe3486f7e47f0cc3047d75da99e447153738efd5097db731d1", size = 2053771, upload-time = "2025-12-26T23:43:38.038Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5c/d274c820da68254c105c6c18b31001c1624f78c9cc7c36f6550ca17cc6ef/stringzilla-4.6.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:221f53f809d7beac636d369a255fd5d9729a6a8b5fe4e38afc30b41f59eccc39", size = 643972, upload-time = "2025-12-26T23:43:39.679Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/5f2a826c7091405db189aac751db6cee5e254fb9b35a99f053889b2d3615/stringzilla-4.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7f890db95eb00e10df429e5d85fdacb4904fbe9be20f59d87c2981b06b2caf1", size = 653518, upload-time = "2025-12-26T23:43:41.188Z" }, + { url = "https://files.pythonhosted.org/packages/78/40/30f450e4c8edc981c72e155ec41419712701e81eb38a5dda7cd07d7bd24e/stringzilla-4.6.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b035ee7301921cd064421cbd08c84e6adb7244472c7679155716d7ac57a95299", size = 586823, upload-time = "2025-12-26T23:43:42.944Z" }, + { url = "https://files.pythonhosted.org/packages/86/ff/ad0c3b795a3454e13d9dd59552029713ccec87bdc5ee62e48f5dd63f2e56/stringzilla-4.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fd7d125408f372aee425b0d0dd998199c0dbca599be9942ce41b745f21c98198", size = 626121, upload-time = "2025-12-26T23:43:44.188Z" }, + { url = "https://files.pythonhosted.org/packages/08/12/9ecda6d1972ddc2a91a857e5305066e2784392d9e27eafbf99b84c79052a/stringzilla-4.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a50fc1237f64fdf79bea5d55c397ac88abc740284ddbfd03bdeab0a0b43711fe", size = 618785, upload-time = "2025-12-26T23:43:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f0/a744c287f1bad754a4f03d4c1b64be8939ff7ea32f345352f3c09ed15fb0/stringzilla-4.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b21708b71907a3f625e37f9248c37c67542f5665740f2205b9050e1fc67b4d87", size = 613150, upload-time = "2025-12-26T23:43:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/46/04/4246dc812a5da8d97176476f79796cfd02e4e1c1ad8ae27f1ca0e869c4eb/stringzilla-4.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dac46e84bcb28b344206dbc21b892c4ec5fa8506ee7027412819be55e75f91e9", size = 1909233, upload-time = "2025-12-26T23:43:48.295Z" }, + { url = "https://files.pythonhosted.org/packages/fb/99/fab077604f45c8ff1d330c26d1cccf94a58ea0a9155c8ba63c038117543a/stringzilla-4.6.0-cp313-cp313-win32.whl", hash = "sha256:51d3c8504a8038b3d1699f366a56a8d4571741c3993af9562b6f227d56c5ad67", size = 114753, upload-time = "2025-12-26T23:43:49.71Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/b9bd35292a126a0c2fde103ad5ce5bfe7384cb6eba4ca34fc5cbcaa7ac96/stringzilla-4.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:0e18442332b72d37465f7959bb72f0be480f4d604c86d165863587113401bb45", size = 162434, upload-time = "2025-12-26T23:43:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/1f5a6b4f8fc405e1e76125be9629ad7390bfcf1be9683a6e2e5602911b68/stringzilla-4.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:33192fe820704803a52b90c566838373f45b295e4064e0679ba4f76bb76b6ce7", size = 123350, upload-time = "2025-12-26T23:43:53.097Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tifffile" +version = "2026.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/cb/2f6d79c7576e22c116352a801f4c3c8ace5957e9aced862012430b62e14f/tifffile-2026.3.3.tar.gz", hash = "sha256:d9a1266bed6f2ee1dd0abde2018a38b4f8b2935cb843df381d70ac4eac5458b7", size = 388745, upload-time = "2026-03-03T19:14:38.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/e4/e804505f87627cd8cdae9c010c47c4485fd8c1ce31a7dd0ab7fcc4707377/tifffile-2026.3.3-py3-none-any.whl", hash = "sha256:e8be15c94273113d31ecb7aa3a39822189dd11c4967e3cc88c178f1ad2fd1170", size = 243960, upload-time = "2026-03-03T19:14:35.808Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "ty" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" }, + { url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" }, + { url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" }, + { url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/app/Actions/Diagnostics/Errors.php b/app/Actions/Diagnostics/Errors.php index 79c66bd841b..59838e25e60 100644 --- a/app/Actions/Diagnostics/Errors.php +++ b/app/Actions/Diagnostics/Errors.php @@ -9,6 +9,7 @@ namespace App\Actions\Diagnostics; use App\Actions\Diagnostics\Pipes\Checks\AdminUserExistsCheck; +use App\Actions\Diagnostics\Pipes\Checks\AiVisionServiceCheck; use App\Actions\Diagnostics\Pipes\Checks\AppUrlMatchCheck; use App\Actions\Diagnostics\Pipes\Checks\AuthDisabledCheck; use App\Actions\Diagnostics\Pipes\Checks\BasicPermissionCheck; @@ -76,6 +77,7 @@ class Errors SupporterCheck::class, ImagickPdfCheck::class, WatermarkerEnabledCheck::class, + AiVisionServiceCheck::class, StatisticsIntegrityCheck::class, WebshopCheck::class, SecurityAdvisoriesCheck::class, diff --git a/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceCheck.php b/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceCheck.php new file mode 100644 index 00000000000..4b4b3a42090 --- /dev/null +++ b/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceCheck.php @@ -0,0 +1,132 @@ +config_manager->getValueAsBool('ai_vision_enabled')) { + return $next($data); + } + + $this->validateServiceUrl($data); + $this->checkServiceHealth($data); + + return $next($data); + } + + /** + * Validate that the AI Vision service URL is configured. + * + * @param DiagnosticData[] &$data + * + * @return void + */ + private function validateServiceUrl(array &$data): void + { + $service_url = config('features.ai-vision.face-url', ''); + if ($service_url === '') { + $data[] = DiagnosticData::error( + 'AI Vision: Service URL is not configured. Set AI_VISION_FACE_URL in your .env file.', + self::class, + [] + ); + } + } + + /** + * Check if the AI Vision service health endpoint is reachable and returns proper data. + * + * @param DiagnosticData[] &$data + * + * @return void + */ + private function checkServiceHealth(array &$data): void + { + $service_url = config('features.ai-vision.face-url', ''); + if ($service_url === '') { + return; + } + + $api_key = config('features.ai-vision.face-api-key', ''); + $health_url = rtrim($service_url, '/') . '/health'; + + try { + $response = Http::withHeaders(['X-API-Key' => $api_key]) + ->timeout(5) + ->get($health_url); + + if (!$response->successful()) { + $data[] = DiagnosticData::error( + 'AI Vision: Service health check failed with status ' . $response->status() . '. The service may be offline or unreachable.', + self::class, + ['Check that the AI Vision service is running at: ' . $service_url] + ); + + return; + } + + $health_data = $response->json(); + if (!is_array($health_data) || !isset($health_data['status'])) { + $data[] = DiagnosticData::error( + 'AI Vision: Service health endpoint returned invalid response format. Expected JSON with "status" field.', + self::class, + ['Response: ' . $response->body()] + ); + + return; + } + + if ($health_data['status'] !== 'ok' && $health_data['status'] !== 'healthy') { + $data[] = DiagnosticData::warn( + 'AI Vision: Service reported unhealthy status: ' . $health_data['status'], + self::class, + [] + ); + } + } catch (\Illuminate\Http\Client\ConnectionException $e) { + $data[] = DiagnosticData::error( + 'AI Vision: Could not connect to service at ' . $health_url, + self::class, + ['Check that the AI Vision service is running and the URL is correct.', $e->getMessage()] + ); + } catch (\Exception $e) { + // @codeCoverageIgnoreStart + $data[] = DiagnosticData::error( + 'AI Vision: Service health check failed with error: ' . $e->getMessage(), + self::class, + [] + ); + // @codeCoverageIgnoreEnd + } + } +} diff --git a/app/Actions/Photo/Create.php b/app/Actions/Photo/Create.php index 759c9cbfcc2..7bec04d7fd9 100644 --- a/app/Actions/Photo/Create.php +++ b/app/Actions/Photo/Create.php @@ -190,6 +190,7 @@ private function handleStandalone(InitDTO $init_dto): Photo Shared\UploadSizeVariantsToS3::class, Shared\ExtractColourPalette::class, Shared\NotifyAlbums::class, + Standalone\AutoScanFacesOnUpload::class, ]; return $this->executePipeOnDTO($pipes, $dto)->getPhoto(); diff --git a/app/Actions/Photo/Pipes/Standalone/AutoScanFacesOnUpload.php b/app/Actions/Photo/Pipes/Standalone/AutoScanFacesOnUpload.php new file mode 100644 index 00000000000..996de3a114f --- /dev/null +++ b/app/Actions/Photo/Pipes/Standalone/AutoScanFacesOnUpload.php @@ -0,0 +1,49 @@ +config_manager->getValueAsString('ai_vision_enabled') !== '1') { + return $state; + } + + // Check if face scanning is enabled + if ($this->config_manager->getValueAsString('ai_vision_face_enabled') !== '1') { + return $state; + } + + // Dispatch face scanning job for the uploaded photo + Log::info("AutoScanFacesOnUpload: dispatching face scan for photo {$state->photo->id}."); + DispatchFaceScanJob::dispatch($state->photo->id); + + return $state; + } +} diff --git a/app/Console/Commands/RescanFailedFaces.php b/app/Console/Commands/RescanFailedFaces.php new file mode 100644 index 00000000000..53179134435 --- /dev/null +++ b/app/Console/Commands/RescanFailedFaces.php @@ -0,0 +1,83 @@ + N minutes back to null, + * making them eligible for a fresh scan + */ +class RescanFailedFaces extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'lychee:rescan-failed-faces + {--stuck-pending : Also reset photos stuck in pending state} + {--older-than=60 : Minutes threshold for stuck-pending reset}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Re-enqueue failed face scan photos; optionally reset stuck pending records.'; + + /** + * Execute the console command. + */ + public function handle(): int + { + $reset_count = 0; + $dispatched = 0; + + // Reset stuck-pending records if requested + if ($this->option('stuck-pending') === true) { + $older_than = (int) $this->option('older-than'); + $cutoff = Carbon::now()->subMinutes($older_than); + + $reset_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->update(['face_scan_status' => null]); + + $this->info("Reset {$reset_count} stuck-pending photo(s) older than {$older_than} minutes."); + Log::info("lychee:rescan-failed-faces reset {$reset_count} stuck-pending records."); + } + + // Re-enqueue failed photos + Photo::query() + ->select('id') + ->where('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->lazyById(200, 'id') + ->each(function (Photo $photo) use (&$dispatched): void { + Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + DispatchFaceScanJob::dispatch($photo->id); + $dispatched++; + }); + + $this->info("Dispatched {$dispatched} re-scan job(s) for failed photos."); + Log::info("lychee:rescan-failed-faces dispatched {$dispatched} re-scan jobs."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/ScanFaces.php b/app/Console/Commands/ScanFaces.php new file mode 100644 index 00000000000..52fbc122412 --- /dev/null +++ b/app/Console/Commands/ScanFaces.php @@ -0,0 +1,67 @@ +option('album'); + + $query = Photo::query()->select('id')->whereNull('face_scan_status'); + + if ($album_id !== null) { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + $dispatched = 0; + + $query->lazyById(200, 'id')->each(function (Photo $photo) use (&$dispatched): void { + Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + DispatchFaceScanJob::dispatch($photo->id); + $dispatched++; + }); + + $this->info("Dispatched {$dispatched} face scan job(s)."); + Log::info("lychee:scan-faces dispatched {$dispatched} jobs."); + + return Command::SUCCESS; + } +} diff --git a/app/Contracts/Http/Requests/HasFace.php b/app/Contracts/Http/Requests/HasFace.php new file mode 100644 index 00000000000..982cfb79f2e --- /dev/null +++ b/app/Contracts/Http/Requests/HasFace.php @@ -0,0 +1,16 @@ +name = $name; + $person->is_searchable = $this->config_manager->getValueAsBool('ai_vision_face_person_is_searchable_default'); + $person->save(); + + return $person; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Admin/Maintenance/BulkScanFaces.php b/app/Http/Controllers/Admin/Maintenance/BulkScanFaces.php new file mode 100644 index 00000000000..a0946986847 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/BulkScanFaces.php @@ -0,0 +1,71 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + return Photo::query() + ->select('id') + ->whereNull('face_scan_status') + ->orWhere('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->count(); + } + + /** + * Enqueue all unscanned photos for face detection. + * + * @return void + */ + public function do(MaintenanceRequest $request): void + { + $batch_size = $request->configs()->getValueAsInt('ai_vision_face_scan_batch_size'); + + $query = Photo::query() + ->select('id') + ->whereNull('face_scan_status') + ->orWhere('face_scan_status', '=', FaceScanStatus::FAILED->value); + + $dispatched = 0; + $query->lazyById($batch_size, 'id')->chunk($batch_size)->each(function ($chunk) use (&$dispatched): void { + $ids = $chunk->pluck('id')->all(); + Photo::whereIn('id', $ids)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + foreach ($ids as $photo_id) { + DispatchFaceScanJob::dispatch($photo_id); + $dispatched++; + } + }); + + Log::info("BulkScanFaces::do — dispatched {$dispatched} scans."); + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/DestroyDismissedFaces.php b/app/Http/Controllers/Admin/Maintenance/DestroyDismissedFaces.php new file mode 100644 index 00000000000..fddd5424716 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/DestroyDismissedFaces.php @@ -0,0 +1,66 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + return Face::where('is_dismissed', '=', true)->count(); + } + + /** + * Do: hard-delete all dismissed faces, their crop files, and their embeddings. + * + * @return array{deleted_count: int} + */ + public function do(MaintenanceRequest $_request): array + { + $dismissed_faces = Face::where('is_dismissed', '=', true)->get(); + $face_ids = $dismissed_faces->pluck('id')->all(); + $count = 0; + + foreach ($dismissed_faces as $face) { + if ($face->crop_token !== null) { + $tok = $face->crop_token; + $path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + Storage::disk('images')->delete($path); + } + $face->delete(); + $count++; + } + + if ($face_ids !== []) { + DeleteFaceEmbeddingsJob::dispatch($face_ids); + } + + return ['deleted_count' => $count]; + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/ResetFaceScanStatus.php b/app/Http/Controllers/Admin/Maintenance/ResetFaceScanStatus.php new file mode 100644 index 00000000000..f1a7e3f32ff --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/ResetFaceScanStatus.php @@ -0,0 +1,75 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + $cutoff = Carbon::now()->subMinutes(self::DEFAULT_OLDER_THAN); + + $stuck_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->count(); + + $failed_count = Photo::where('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->count(); + + return $stuck_count + $failed_count; + } + + /** + * Do: reset both stuck-pending (older than threshold) and failed photos to null. + * + * @return array{reset_count: int} + */ + public function do(MaintenanceRequest $_request): array + { + $cutoff = Carbon::now()->subMinutes(self::DEFAULT_OLDER_THAN); + + // Reset failed scans + $failed_count = Photo::where('face_scan_status', '=', FaceScanStatus::FAILED->value) + ->update(['face_scan_status' => null]); + + // Reset stuck-pending scans + $stuck_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->update(['face_scan_status' => null]); + + return ['reset_count' => $failed_count + $stuck_count]; + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/ResetStuckFaces.php b/app/Http/Controllers/Admin/Maintenance/ResetStuckFaces.php new file mode 100644 index 00000000000..145a9d7e62d --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/ResetStuckFaces.php @@ -0,0 +1,59 @@ +subMinutes(self::DEFAULT_OLDER_THAN); + + return Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->count(); + } + + /** + * Do: reset stuck pending photos back to null and return the count reset. + * + * @return array{reset_count: int} + */ + public function do(ResetStuckFacesRequest $request): array + { + $cutoff = Carbon::now()->subMinutes($request->olderThanMinutes()); + + $reset_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value) + ->where('updated_at', '<', $cutoff) + ->update(['face_scan_status' => null]); + + return ['reset_count' => $reset_count]; + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php b/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php new file mode 100644 index 00000000000..8448eb8403b --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/RunFaceClustering.php @@ -0,0 +1,70 @@ +configs()->getValueAsBool('ai_vision_enabled') ? 1 : 0; + } + + /** + * Trigger face clustering in the AI Vision Python service. + * + * @return JsonResponse + */ + public function do(MaintenanceRequest $request): JsonResponse + { + $service_url = config('features.ai-vision.face-url', ''); + $api_key = config('features.ai-vision.face-api-key', ''); + + if ($service_url === '') { + return response()->json(['status' => 'error', 'message' => 'AI Vision service not configured.'], 503); + } + + try { + $response = Http::withHeaders(['X-API-Key' => $api_key]) + ->post($service_url . '/cluster'); + + if ($response->status() === 202) { + return response()->json(['status' => 'dispatched', 'message' => 'Clustering job accepted; results will be sent via callback.'], 202); + } + + if ($response->successful()) { + return response()->json(['status' => 'dispatched'], 200); + } + + Log::warning('RunFaceClustering: /cluster returned HTTP ' . $response->status() . '.'); + + return response()->json(['status' => 'error', 'message' => 'Clustering service returned HTTP ' . $response->status()], 503); + } catch (\Exception $e) { + Log::warning('RunFaceClustering: request failed: ' . $e->getMessage()); + + return response()->json(['status' => 'error', 'message' => $e->getMessage()], 503); + } + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/SyncFaceEmbeddings.php b/app/Http/Controllers/Admin/Maintenance/SyncFaceEmbeddings.php new file mode 100644 index 00000000000..dc6875a3e46 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/SyncFaceEmbeddings.php @@ -0,0 +1,100 @@ +configs()->getValueAsBool('ai_vision_enabled')) { + return 0; + } + + $service = app(FacialRecognitionService::class); + $health = $service->checkHealth(); + + if ($health === null) { + Log::warning('SyncFaceEmbeddings::check — AI Vision service /health returned null.'); + + return 0; + } + + $lychee_count = Face::count(); + $ai_count = $health['embedding_count']; + + return abs($lychee_count - $ai_count); + } + + /** + * Do: synchronize all face embeddings from AI Vision service. + * + * Updates existing faces with latest metadata, preserving is_dismissed flag. + * + * @return array{synced_count: int, missing_in_ai: int} + */ + public function do(MaintenanceRequest $_request): array + { + $service = app(FacialRecognitionService::class); + $export = $service->syncFaceEmbeddings(); + + if ($export === null) { + Log::warning('SyncFaceEmbeddings::do — AI Vision service /embeddings/export returned null.'); + + return ['synced_count' => 0, 'missing_in_ai' => 0]; + } + + $ai_face_ids = []; + $synced = 0; + + foreach ($export['embeddings'] as $item) { + $face_id = $item['lychee_face_id']; + $ai_face_ids[] = $face_id; + + // Update or create face record (preserving is_dismissed if it exists) + $face = Face::find($face_id); + + if ($face !== null) { + // Update existing face, keeping is_dismissed flag + $face->photo_id = $item['photo_id']; + $face->laplacian_variance = $item['laplacian_variance']; + $face->save(); + $synced++; + } else { + Log::warning("SyncFaceEmbeddings::do — Face {$face_id} exists in AI Vision but not in Lychee database."); + } + } + + // Count faces in Lychee that are missing in AI Vision + $lychee_count = Face::count(); + $missing_in_ai = $lychee_count - count($ai_face_ids); + + Log::info("SyncFaceEmbeddings::do — synced {$synced} faces, {$missing_in_ai} in Lychee but not in AI Vision."); + + return ['synced_count' => $synced, 'missing_in_ai' => max(0, $missing_in_ai)]; + } +} diff --git a/app/Http/Controllers/AiVision/AlbumPeopleController.php b/app/Http/Controllers/AiVision/AlbumPeopleController.php new file mode 100644 index 00000000000..81a1d7312e3 --- /dev/null +++ b/app/Http/Controllers/AiVision/AlbumPeopleController.php @@ -0,0 +1,63 @@ +album(); + $user = Auth::user(); + + $query = Person::query() + ->select('persons.*') + ->join('faces', 'faces.person_id', '=', 'persons.id') + ->join('photo_album', 'photo_album.photo_id', '=', 'faces.photo_id') + ->where('photo_album.album_id', '=', $album->id) + ->where('faces.is_dismissed', '=', false) + ->whereNotNull('faces.person_id') + ->orderBy('persons.name'); + + // Non-admin: only show searchable persons, plus the person linked to the current user + if (!($user?->may_administrate === true)) { + $user_id = $user?->id; + $query->where(function ($q) use ($user_id): void { + $q->where('persons.is_searchable', '=', true); + if ($user_id !== null) { + $q->orWhere('persons.user_id', '=', $user_id); + } + }); + } + + $persons = $query->distinct()->paginate(50); + + return new PaginatedPersonsResource($persons); + } +} diff --git a/app/Http/Controllers/AiVision/FaceClusterController.php b/app/Http/Controllers/AiVision/FaceClusterController.php new file mode 100644 index 00000000000..a5985a9c52a --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceClusterController.php @@ -0,0 +1,103 @@ +query('page', '1'); + $totals = Face::query() + ->whereNotNull('cluster_label') + ->where('cluster_label', '>', -1) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->selectRaw('cluster_label, COUNT(*) as face_count') + ->groupBy('cluster_label') + ->orderBy('face_count', 'desc') + ->get(); + $total = $totals->count(); + $paginated = $totals->forPage($page, self::PER_PAGE); + $items = $paginated->map(function (Face $row): ClusterPreviewResource { + $cluster_label = (int) $row->cluster_label; + $face_count = (int) $row->face_count; // @phpstan-ignore property.notFound (see line 34) + // This is a n+1 query ... + $samples = Face::query() + ->where('cluster_label', '=', $cluster_label) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->whereNotNull('crop_token') + ->orderByDesc('confidence') + ->limit(self::SAMPLE_SIZE) + ->pluck('crop_token') + ->map(static fn ($tok) => 'uploads/faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg') + ->all(); + + return new ClusterPreviewResource($cluster_label, $face_count, $samples); + }); + + $paginator = new LengthAwarePaginator($items, $total, self::PER_PAGE, $page, ['path' => $request->url()]); + + return new PaginatedClustersResource($paginator); + } + + public function assign(ClusterAssignRequest $request, int $label, PersonFactory $person_factory): array + { + $person = $person_factory->findOrCreate($request->person_id, $request->new_person_name); + $count = Face::where('cluster_label', '=', $label) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->update(['person_id' => $person->id]); + + return ['assigned_count' => $count]; + } + + public function dismiss(ClusterDismissRequest $request, int $label): array + { + $count = Face::where('cluster_label', '=', $label) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->update(['is_dismissed' => true]); + + return ['dismissed_count' => $count]; + } + + /** + * Remove selected faces from a cluster by setting cluster_label = NULL. + * Only affects qualifying faces (cluster_label = label, person_id IS NULL, is_dismissed = false). + * + * POST /FaceDetection/clusters/{label}/uncluster + * + * @return array{unclustered_count: int} + */ + public function uncluster(UnclusterFacesRequest $request, int $label): array + { + $count = Face::whereIn('id', $request->face_ids) + ->where('cluster_label', '=', $label) + ->whereNull('person_id') + ->where('is_dismissed', '=', false) + ->update(['cluster_label' => null]); + + return ['unclustered_count' => $count]; + } +} diff --git a/app/Http/Controllers/AiVision/FaceController.php b/app/Http/Controllers/AiVision/FaceController.php new file mode 100644 index 00000000000..9a27adf3910 --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceController.php @@ -0,0 +1,129 @@ +face(); + + if ($request->person_id === null && trim($request->new_person_name ?? '') === '') { + // Unassign: return face to unassigned pool + $face->person_id = null; + } else { + $person = $person_factory->findOrCreate($request->person_id, $request->new_person_name); + $face->person_id = $person->id; + } + $face->save(); + + return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person'])); + } + + /** + * Toggle the is_dismissed flag on a face. + * Only the photo owner or admin can dismiss/undismiss. + * + * PATCH /Face/{id} + * + * @return FaceResource + */ + public function toggleDismissed(ToggleDismissedRequest $request, string $id): FaceResource + { + $face = $request->face(); + $face->is_dismissed = !$face->is_dismissed; + $face->save(); + + return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person'])); + } + + /** + * Batch face operations: unassign all selected faces, or assign them to an existing/new person. + * + * POST /Face/batch + * + * @return array{affected_count: int, person_id: string|null} + */ + public function batch(BatchFaceRequest $request, PersonFactory $person_factory): array + { + $face_ids = $request->face_ids; + + if ($request->action === 'unassign') { + $count = Face::whereIn('id', $face_ids)->update(['person_id' => null]); + + return ['affected_count' => $count, 'person_id' => null]; + } + + // action === 'assign' + if ($request->person_id !== null) { + $person = Person::findOrFail($request->person_id); + } else { + $person = $person_factory->findOrCreate(null, $request->new_person_name ?? ''); + } + + $count = Face::whereIn('id', $face_ids)->update(['person_id' => $person->id]); + + return ['affected_count' => $count, 'person_id' => $person->id]; + } + + /** + * Hard-delete all dismissed faces and remove their crop files. + * Admin-only. + * + * DELETE /Face/dismissed + * + * @return array{deleted_count: int} + */ + public function destroyDismissed(DestroyDismissedFacesRequest $_request): array + { + $dismissed_faces = Face::where('is_dismissed', '=', true)->get(); + $face_ids = $dismissed_faces->pluck('id')->all(); + $count = 0; + + foreach ($dismissed_faces as $face) { + // Delete crop file + if ($face->crop_token !== null) { + $tok = $face->crop_token; + $path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + Storage::disk('images')->delete($path); + } + $face->delete(); + $count++; + } + + if ($face_ids !== []) { + DeleteFaceEmbeddingsJob::dispatch($face_ids); + } + + return ['deleted_count' => $count]; + } +} diff --git a/app/Http/Controllers/AiVision/FaceDetectionController.php b/app/Http/Controllers/AiVision/FaceDetectionController.php new file mode 100644 index 00000000000..3ca9e565345 --- /dev/null +++ b/app/Http/Controllers/AiVision/FaceDetectionController.php @@ -0,0 +1,329 @@ +photoIds(); + $album_id = $request->album()?->id; + $force = $request->force(); + + // Collect target photos + $query = Photo::query()->select('id'); + + if ($photo_ids !== null) { + $query->whereIn('id', $photo_ids); + } else { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + if (!$force) { + // Skip photos that have at least one face with a person assigned + $query->whereDoesntHave('faces', fn ($q) => $q->whereNotNull('person_id')); + } + + $batch_size = app(ConfigManager::class)->getValueAsInt('ai_vision_face_scan_batch_size'); + + $dispatched = 0; + $query->lazyById($batch_size, 'id')->chunk($batch_size)->each(function ($chunk) use (&$dispatched): void { + $ids = $chunk->pluck('id')->all(); + + // Set status to pending in bulk + Photo::whereIn('id', $ids)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + // Dispatch a job for each photo + foreach ($ids as $photo_id) { + DispatchFaceScanJob::dispatch($photo_id); + $dispatched++; + } + }); + + Log::info("FaceDetectionController::scan — dispatched {$dispatched} scans."); + + return response()->noContent(202); + } + + /** + * Receive face detection results callback from the Python service. + * Authentication is exclusively via X-API-Key header; no user session required. + * + * POST /FaceDetection/results + * + * @return array> + */ + public function results(FaceDetectionResultsRequest $request): array + { + $photo_id = $request->photoId(); + + if ($request->status() === 'error') { + Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::FAILED->value]); + Log::info("FaceDetectionController::results — photo {$photo_id} face scan failed: " . ($request->message() ?? 'unknown error')); + + return ['faces' => []]; + } + + // Process success payload + $mapping = $this->processFaceResults($photo_id, $request->faces()); + + Photo::where('id', '=', $photo_id)->update(['face_scan_status' => FaceScanStatus::COMPLETED->value]); + + return ['faces' => $mapping]; + } + + /** + * Admin: enqueue all photos with face_scan_status IS NULL for face detection. + * + * POST /FaceDetection/bulk-scan + * + * @return \Illuminate\Http\Response + */ + public function bulkScan(BulkScanRequest $request): \Illuminate\Http\Response + { + $album_id = $request->album()?->id; + $batch_size = $request->configs()->getValueAsInt('ai_vision_face_scan_batch_size'); + + $query = Photo::query()->select('id')->whereNull('face_scan_status')->orWhere('face_scan_status', '=', FaceScanStatus::FAILED->value); + + if ($album_id !== null) { + $query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id)); + } + + $dispatched = 0; + $query->lazyById($batch_size, 'id')->chunk($batch_size)->each(function ($chunk) use (&$dispatched): void { + $ids = $chunk->pluck('id')->all(); + Photo::whereIn('id', $ids)->update(['face_scan_status' => FaceScanStatus::PENDING->value]); + + foreach ($ids as $photo_id) { + DispatchFaceScanJob::dispatch($photo_id); + $dispatched++; + } + }); + + Log::info("FaceDetectionController::bulkScan — dispatched {$dispatched} scans."); + + return response()->noContent(202); + } + + /** + * Receive face clustering results callback from the Python service. + * Authentication is exclusively via X-API-Key header; no user session required. + * + * POST /FaceDetection/cluster-results + * + * @return \Illuminate\Http\Response + */ + public function clusterResults(ClusterResultsRequest $request): \Illuminate\Http\Response + { + DB::transaction(function () use ($request): void { + // Reset all existing cluster labels so stale assignments are removed. + Face::whereNotNull('cluster_label')->update(['cluster_label' => null]); + + // Apply new cluster labels in bulk. + foreach ($request->labels() as $item) { + Face::where('id', '=', $item['face_id']) + ->update(['cluster_label' => $item['cluster_label']]); + } + + // Upsert cross-cluster suggestions. + foreach ($request->suggestions() as $sug) { + FaceSuggestion::updateOrCreate( + ['face_id' => $sug['face_id'], 'suggested_face_id' => $sug['suggested_face_id']], + ['confidence' => $sug['confidence']], + ); + } + }); + + Log::info('FaceDetectionController::clusterResults — updated ' . count($request->labels()) . ' cluster labels.'); + + return response()->noContent(202); + } + + /** + * Process incoming face detection results for a photo. + * IoU-matches new faces against old faces to preserve person assignments. + * + * @param string $photo_id + * @param array}> $incoming_faces + * + * @return array + */ + private function processFaceResults(string $photo_id, array $incoming_faces): array + { + $iou_threshold = (float) config('features.ai-vision.face-rescan-iou-threshold', self::DEFAULT_IOU_THRESHOLD); + + // Load existing faces for this photo + $old_faces = Face::where('photo_id', '=', $photo_id)->get(); + + // For each incoming face, find best IoU match among old faces + // Track matched old face indices to avoid double-matching + $matched_old_indices = []; + $person_ids_for_new = array_fill(0, count($incoming_faces), null); + + foreach ($incoming_faces as $new_idx => $new_face) { + $best_iou = $iou_threshold; + $best_old_idx = null; + + foreach ($old_faces as $old_idx => $old_face) { + if (in_array($old_idx, $matched_old_indices, true)) { + continue; + } + + $iou = $this->computeIoU( + $new_face['x'], $new_face['y'], $new_face['width'], $new_face['height'], + $old_face->x, $old_face->y, $old_face->width, $old_face->height + ); + + if ($iou > $best_iou) { + $best_iou = $iou; + $best_old_idx = $old_idx; + } + } + + if ($best_old_idx !== null) { + $matched_old_indices[] = $best_old_idx; + $person_ids_for_new[$new_idx] = $old_faces[$best_old_idx]->person_id; + } + } + + // Delete ALL old face records (and their crops) — they will be replaced + foreach ($old_faces as $old_face) { + $this->deleteCropFile($old_face->crop_token); + } + Face::where('photo_id', '=', $photo_id)->delete(); + FaceSuggestion::whereNotExists(function ($query): void { + $query->select(DB::raw(1))->from('faces')->whereColumn('face_suggestions.face_id', 'faces.id'); + })->delete(); + + // Create new face records and return embedding_id → lychee_face_id mapping + $mapping = []; + + foreach ($incoming_faces as $idx => $face_data) { + $tok = Str::random(24); + $crop_path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + + // Store crop file if provided + if (isset($face_data['crop']) && $face_data['crop'] !== '') { + try { + $decoded = base64_decode($face_data['crop']); + Storage::disk('images')->put($crop_path, $decoded); + } catch (MiscException) { + $tok = null; + } + } else { + $tok = null; + } + + $face = new Face(); + $face->photo_id = $photo_id; + $face->person_id = $person_ids_for_new[$idx]; + $face->x = $face_data['x']; + $face->y = $face_data['y']; + $face->width = $face_data['width']; + $face->height = $face_data['height']; + $face->confidence = $face_data['confidence']; + $face->laplacian_variance = $face_data['laplacian_variance'] ?? 0.0; + $face->crop_token = $tok; + $face->is_dismissed = false; + $face->save(); + + // We filter out the id which no longer exists. + $suggestions = $face_data['suggestions'] ?? []; + $suggestion_ids = collect($suggestions)->map(fn ($sug) => $sug['lychee_face_id']); + $existing_ids = Face::whereIn('id', $suggestion_ids)->pluck('id')->all(); + $valid_suggestions = collect($suggestions)->filter(fn ($sug) => in_array($sug['lychee_face_id'], $existing_ids, true)); + + // Create suggestion records + foreach ($valid_suggestions as $suggestion) { + $sug = new FaceSuggestion(); + $sug->face_id = $face->id; + $sug->suggested_face_id = $suggestion['lychee_face_id']; + $sug->confidence = $suggestion['confidence']; + $sug->save(); + } + + $mapping[] = [ + 'embedding_id' => $face_data['embedding_id'], + 'lychee_face_id' => $face->id, + ]; + } + + return $mapping; + } + + /** + * Compute Intersection over Union (IoU) for two bounding boxes. + * All coordinates in normalized [0,1] space. + */ + private function computeIoU( + float $x1, float $y1, float $w1, float $h1, + float $x2, float $y2, float $w2, float $h2, + ): float { + $ix = max(0.0, min($x1 + $w1, $x2 + $w2) - max($x1, $x2)); + $iy = max(0.0, min($y1 + $h1, $y2 + $h2) - max($y1, $y2)); + $inter = $ix * $iy; + + if ($inter === 0.0) { + return 0.0; + } + + $union = $w1 * $h1 + $w2 * $h2 - $inter; + + return $union > 0.0 ? $inter / $union : 0.0; + } + + /** + * Delete a face crop file from storage if the token is set. + */ + private function deleteCropFile(?string $crop_token): void + { + if ($crop_token === null) { + return; + } + + $path = 'faces/' . substr($crop_token, 0, 2) . '/' . substr($crop_token, 2, 2) . '/' . $crop_token . '.jpg'; + Storage::disk('images')->delete($path); + } +} diff --git a/app/Http/Controllers/AiVision/PeopleController.php b/app/Http/Controllers/AiVision/PeopleController.php new file mode 100644 index 00000000000..38b9731fb20 --- /dev/null +++ b/app/Http/Controllers/AiVision/PeopleController.php @@ -0,0 +1,113 @@ +orderBy('name'); + + if ($user === null || !$user->may_administrate) { + // Non-admin: only show searchable persons, plus the person linked to the current user + $user_id = $user?->id; + $query->where(function ($q) use ($user_id): void { + $q->where('is_searchable', '=', true); + if ($user_id !== null) { + $q->orWhere('user_id', '=', $user_id); + } + }); + } + + $persons = $query->paginate(50); + + return new PaginatedPersonsResource($persons); + } + + /** + * Show a single person. + * + * @return PersonResource + */ + public function show(ShowPersonRequest $request): PersonResource + { + return PersonResource::fromModel($request->person()); + } + + /** + * Create a new Person. + * + * @return PersonResource + */ + public function store(StorePersonRequest $request): PersonResource + { + $is_searchable_default = app(ConfigManager::class)->getValueAsString('ai_vision_face_person_is_searchable_default') === '1'; + + $person = new Person(); + $person->name = $request->name(); + $person->user_id = $request->userId(); + $person->is_searchable = $is_searchable_default; + $person->save(); + + return PersonResource::fromModel($person); + } + + /** + * Update a Person (name and/or searchability). + * + * @return PersonResource + */ + public function update(UpdatePersonRequest $request): PersonResource + { + $person = $request->person(); + + if ($request->isSearchable() !== null) { + $person->is_searchable = $request->isSearchable(); + } + + if ($request->name() !== null) { + $person->name = $request->name(); + } + + $person->save(); + + return PersonResource::fromModel($person); + } + + /** + * Delete a Person. All associated faces will have their person_id set to null. + */ + public function destroy(DestroyPersonRequest $request): void + { + $person = Person::findOrFail($request->personId()); + $person->delete(); + } +} diff --git a/app/Http/Controllers/AiVision/PersonClaimController.php b/app/Http/Controllers/AiVision/PersonClaimController.php new file mode 100644 index 00000000000..61574a7d7eb --- /dev/null +++ b/app/Http/Controllers/AiVision/PersonClaimController.php @@ -0,0 +1,94 @@ +person(); + + if (!$user->may_administrate) { + // Non-admin: conflict if already claimed by another user + if ($person->user_id !== null && $person->user_id !== $user->id) { + abort(409, 'This person is already claimed by another user.'); + } + + // Non-admin: ensure user doesn't already have a different person + $existing = Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->first(); + if ($existing !== null) { + abort(409, 'You have already claimed a different person.'); + } + } else { + // Admin force-claim: clear any existing link for this user + Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->update(['user_id' => null]); + } + + $person->user_id = $user->id; + $person->save(); + + return PersonResource::fromModel($person->fresh()); + } + + /** + * Unclaim a Person — remove the user_id link. + * Only the linked user or admin can unclaim. + */ + public function unclaim(UnclaimPersonRequest $request, string $id): void + { + $person = $request->person(); + + $person->user_id = null; + $person->save(); + } + + /** + * Merge source Person into target Person. + * All Face records from source are reassigned to target; source is deleted. + * + * URL: POST /Person/{id}/merge where {id} is the TARGET person (kept). + * Body: source_person_id = the person to be destroyed. + * + * @return PersonResource + */ + public function merge(MergePersonRequest $request, string $id): PersonResource + { + $target = Person::findOrFail($id); + $source = Person::findOrFail($request->sourcePersonId()); + + // Reassign all faces from source to target + Face::where('person_id', '=', $source->id)->update(['person_id' => $target->id]); + + // Delete source person + $source->delete(); + + return PersonResource::fromModel($target->fresh()); + } +} diff --git a/app/Http/Controllers/AiVision/PersonPhotosController.php b/app/Http/Controllers/AiVision/PersonPhotosController.php new file mode 100644 index 00000000000..a6e1968d21c --- /dev/null +++ b/app/Http/Controllers/AiVision/PersonPhotosController.php @@ -0,0 +1,81 @@ +may_administrate === true) && !$person->is_searchable && $person->user_id !== $user?->id) { + abort(403); + } + + $query = Photo::query() + ->select('photos.*') + ->join('faces', 'faces.photo_id', '=', 'photos.id') + ->where('faces.person_id', '=', $id) + ->with(['size_variants', 'tags', 'palette', 'statistics', 'rating', + 'faces.person', 'faces.suggestions.suggestedFace.person']) + ->orderBy('photos.taken_at', 'desc'); + + // Access control: restrict to photos accessible to the current user + if (!($user?->may_administrate === true)) { + $query->where(function ($q) use ($user): void { + // Photos in at least one public album (base_albums.is_public = true) + $q->whereExists(function ($sub): void { + $sub->select(DB::raw(1)) + ->from('photo_album') + ->join('base_albums', 'base_albums.id', '=', 'photo_album.album_id') + ->whereColumn('photo_album.photo_id', 'photos.id') + ->where('base_albums.is_public', '=', true); + }); + + // OR photos owned by the authenticated user + if ($user !== null) { + $q->orWhere('photos.owner_id', '=', $user->id); + } + }); + } + + $paginated = $query->distinct()->paginate(50); + + return new PaginatedPhotosResource( + paginated_photos: $paginated, + album_id: null, + should_downgrade: false, + photo_timeline: null + ); + } +} diff --git a/app/Http/Controllers/AiVision/SelfieClaimController.php b/app/Http/Controllers/AiVision/SelfieClaimController.php new file mode 100644 index 00000000000..3fd1e086542 --- /dev/null +++ b/app/Http/Controllers/AiVision/SelfieClaimController.php @@ -0,0 +1,104 @@ +isConfigured()) { + abort(503, 'AI Vision service is not configured.'); + } + + $selfie = $request->selfie(); + + try { + $data = $facial_recognition_service->matchSelfie($selfie->getRealPath(), $selfie->getClientOriginalName()); + } catch (\Exception $e) { + Log::warning('AI Vision selfie match request failed: ' . $e->getMessage()); + abort(503, 'AI Vision service is unavailable.'); + } finally { + // Discard selfie immediately + unlink($selfie->getRealPath()); + } + + if ($data === null) { + abort(503, 'AI Vision service returned an error.'); + } + + /** @var array{matches: array} $data */ + $matches = $data['matches'] ?? []; + + if ($matches === []) { + abort(404, 'No matching person found for the selfie.'); + } + + $threshold = (float) $config_manager->getValueAsString('ai_vision_face_selfie_confidence_threshold'); + $best_match = $matches[0]; + + if ($best_match['confidence'] < $threshold) { + abort(404, 'No matching person found with sufficient confidence.'); + } + + $face = Face::findOrFail($best_match['lychee_face_id']); + + if ($face->person_id === null) { + abort(404, 'No person associated with the matched face.'); + } + + $person = Person::findOrFail($face->person_id); + + // Check if already claimed by another user + if ($person->user_id !== null && $person->user_id !== $user->id) { + abort(409, 'This person is already claimed by another user.'); + } + + // Ensure user doesn't already have a different person linked + $existing = Person::where('user_id', '=', $user->id)->where('id', '!=', $person->id)->first(); + if ($existing !== null) { + abort(409, 'You have already claimed a different person.'); + } + + // Check if user claims are allowed + if (!$user->may_administrate && !$config_manager->getValueAsBool('ai_vision_face_allow_user_claim')) { + abort(403, 'User claims are not permitted by the administrator.'); + } + + $person->user_id = $user->id; + $person->save(); + + return PersonResource::fromModel($person->fresh()); + } +} diff --git a/app/Http/Middleware/ConfigIntegrity.php b/app/Http/Middleware/ConfigIntegrity.php index d9c043baf81..12a0c416324 100644 --- a/app/Http/Middleware/ConfigIntegrity.php +++ b/app/Http/Middleware/ConfigIntegrity.php @@ -105,6 +105,13 @@ class ConfigIntegrity 'contact_form_security_question', 'contact_form_security_answer', 'contact_form_custom_consent_required', + 'ai_vision_enabled', + 'ai_vision_face_enabled', + 'ai_vision_face_permission_mode', + 'ai_vision_face_selfie_confidence_threshold', + 'ai_vision_face_person_is_searchable_default', + 'ai_vision_face_allow_user_claim', + 'ai_vision_face_scan_batch_size', ]; public const PRO_FIELDS = [ diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index e19c39cae3c..dc9270793fd 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -27,6 +27,8 @@ class VerifyCsrfToken extends Middleware '/api/v2/Zip', '/api/v2/Shop/Checkout/Finalize/*', '/api/v2/Photo::random', + '/api/v2/FaceDetection/results', // This is only exposed internally and auth with a token. + 'api/v2/FaceDetection/cluster-results', // This is only exposed internally and auth with a token. ]; /** diff --git a/app/Http/Requests/Face/AssignFaceRequest.php b/app/Http/Requests/Face/AssignFaceRequest.php new file mode 100644 index 00000000000..e246ad1b5dc --- /dev/null +++ b/app/Http/Requests/Face/AssignFaceRequest.php @@ -0,0 +1,71 @@ + ['required', new RandomIDRule(false)], + 'person_id' => ['nullable', 'string'], + 'new_person_name' => ['nullable', 'string', 'max:255'], + ]; + } + + public function withValidator(\Illuminate\Validation\Validator $validator): void + { + $validator->after(function (\Illuminate\Validation\Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + // Allowing both to be null/absent is valid — it means unassign. + // We only reject the case where both new_person_name and person_id are + // provided simultaneously with non-null values (ambiguous intent). + $values = $validator->validated(); + $has_person = isset($values['person_id']) && $values['person_id'] !== null; + $has_name = isset($values['new_person_name']) && $values['new_person_name'] !== null; + if ($has_person && $has_name) { + $validator->errors()->add('person_id', 'Provide either person_id or new_person_name, not both.'); + } + }); + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face = Face::findOrFail($values['id']); + $this->person_id = $values['person_id'] ?? null; + $this->new_person_name = $values['new_person_name'] ?? 'people.unknown'; + } +} diff --git a/app/Http/Requests/Face/BatchFaceRequest.php b/app/Http/Requests/Face/BatchFaceRequest.php new file mode 100644 index 00000000000..28ad2e26ce8 --- /dev/null +++ b/app/Http/Requests/Face/BatchFaceRequest.php @@ -0,0 +1,64 @@ + ['required', 'array', 'min:1'], + 'face_ids.*' => ['required', 'string', 'exists:faces,id'], + 'action' => ['required', 'string', 'in:unassign,assign'], + 'person_id' => ['nullable', 'string', 'exists:persons,id'], + 'new_person_name' => ['nullable', 'string', 'max:255'], + ]; + } + + public function withValidator(\Illuminate\Validation\Validator $validator): void + { + $validator->after(function (\Illuminate\Validation\Validator $validator): void { + if ($validator->errors()->isNotEmpty()) { + return; + } + + $values = $validator->validated(); + if ($values['action'] === 'assign') { + $has_person = isset($values['person_id']) && $values['person_id'] !== null; + $has_name = isset($values['new_person_name']) && $values['new_person_name'] !== null && $values['new_person_name'] !== ''; + if (!$has_person && !$has_name) { + $validator->errors()->add('person_id', 'Either person_id or new_person_name must be provided for assign action.'); + } + } + }); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face_ids = $values['face_ids']; + $this->action = $values['action']; + $this->person_id = $values['person_id'] ?? null; + $this->new_person_name = $values['new_person_name'] ?? null; + } +} diff --git a/app/Http/Requests/Face/BulkScanRequest.php b/app/Http/Requests/Face/BulkScanRequest.php new file mode 100644 index 00000000000..180eb82d81d --- /dev/null +++ b/app/Http/Requests/Face/BulkScanRequest.php @@ -0,0 +1,58 @@ + ['nullable', new RandomIDRule(true)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $album_id = $values['album_id'] ?? null; + $this->album = $album_id !== null ? Album::findOrFail($album_id) : null; + } + + public function album(): ?Album + { + return $this->album; + } +} diff --git a/app/Http/Requests/Face/ClusterAssignRequest.php b/app/Http/Requests/Face/ClusterAssignRequest.php new file mode 100644 index 00000000000..e28915586f1 --- /dev/null +++ b/app/Http/Requests/Face/ClusterAssignRequest.php @@ -0,0 +1,39 @@ + ['nullable', 'string', 'exists:persons,id'], + 'new_person_name' => ['nullable', 'string', 'max:255'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person_id = $values['person_id'] ?? null; + $this->new_person_name = $values['new_person_name'] ?? 'people.unknown'; + } +} diff --git a/app/Http/Requests/Face/ClusterDismissRequest.php b/app/Http/Requests/Face/ClusterDismissRequest.php new file mode 100644 index 00000000000..d11cd4085b6 --- /dev/null +++ b/app/Http/Requests/Face/ClusterDismissRequest.php @@ -0,0 +1,22 @@ + */ + private array $labels = []; + + /** @var array */ + private array $suggestions = []; + + public function authorize(): bool + { + $expected_key = config('features.ai-vision.face-api-key', ''); + $provided_key = $this->header('X-API-Key', ''); + + return $expected_key !== '' && $provided_key === $expected_key; + } + + public function rules(): array + { + return [ + 'labels' => 'required|array', + 'labels.*.face_id' => 'required|string', + 'labels.*.cluster_label' => 'required|integer', + 'suggestions' => 'sometimes|array', + 'suggestions.*.face_id' => 'required|string|exists:faces,id', + 'suggestions.*.suggested_face_id' => 'required|string|exists:faces,id', + 'suggestions.*.confidence' => 'required|numeric', + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->labels = $values['labels'] ?? []; + $this->suggestions = $values['suggestions'] ?? []; + Log::warning('Received face clustering results with ' . count($this->labels) . ' labels and ' . count($this->suggestions) . ' suggestions.', + [ + 'labels' => $this->labels, + 'suggestions' => $this->suggestions, + ]); + } + + /** + * @return array + */ + public function labels(): array + { + return $this->labels; + } + + /** + * @return array + */ + public function suggestions(): array + { + return $this->suggestions; + } +} diff --git a/app/Http/Requests/Face/DestroyDismissedFacesRequest.php b/app/Http/Requests/Face/DestroyDismissedFacesRequest.php new file mode 100644 index 00000000000..d357f02f808 --- /dev/null +++ b/app/Http/Requests/Face/DestroyDismissedFacesRequest.php @@ -0,0 +1,22 @@ +}> */ + private array $faces = []; + private ?string $error_code = null; + private ?string $message = null; + + /** + * {@inheritDoc} + * + * Validates the X-API-Key header against the configured secret. + */ + public function authorize(): bool + { + $expected_key = config('features.ai-vision.face-api-key', ''); + $provided_key = $this->header('X-API-Key', ''); + + return $expected_key !== '' && $provided_key === $expected_key; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'photo_id' => 'required|string|exists:photos,id', + 'status' => 'required|string|in:success,error', + 'faces' => 'sometimes|array', + 'faces.*.x' => 'required_with:faces|numeric', + 'faces.*.y' => 'required_with:faces|numeric', + 'faces.*.width' => 'required_with:faces|numeric', + 'faces.*.height' => 'required_with:faces|numeric', + 'faces.*.confidence' => 'required_with:faces|numeric', + 'faces.*.laplacian_variance' => 'required_with:faces|numeric', + 'faces.*.embedding_id' => 'required_with:faces|string', + 'faces.*.crop' => 'sometimes|string', + 'faces.*.suggestions' => 'sometimes|array', + 'faces.*.suggestions.*.lychee_face_id' => 'required|string', + 'faces.*.suggestions.*.confidence' => 'required|numeric', + 'error_code' => 'sometimes|string', + 'message' => 'sometimes|string', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo_id = $values['photo_id']; + $this->status = $values['status']; + $this->faces = $values['faces'] ?? []; + $this->error_code = $values['error_code'] ?? null; + $this->message = $values['message'] ?? null; + } + + public function photoId(): string + { + return $this->photo_id; + } + + public function status(): string + { + return $this->status; + } + + /** + * @return array}> + */ + public function faces(): array + { + return $this->faces; + } + + public function errorCode(): ?string + { + return $this->error_code; + } + + public function message(): ?string + { + return $this->message; + } +} diff --git a/app/Http/Requests/Face/ScanPhotosRequest.php b/app/Http/Requests/Face/ScanPhotosRequest.php new file mode 100644 index 00000000000..601f21716a2 --- /dev/null +++ b/app/Http/Requests/Face/ScanPhotosRequest.php @@ -0,0 +1,92 @@ +album]); + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'photo_ids' => 'nullable|array', + 'photo_ids.*' => ['string', new RandomIDRule(false)], + 'album_id' => ['nullable', new RandomIDRule(true)], + 'force' => 'sometimes|boolean', + ]; + } + + /** + * {@inheritDoc} + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $values = $validator->validated(); + if (($values['photo_ids'] ?? null) === null && ($values['album_id'] ?? null) === null) { + $validator->errors()->add('photo_ids', 'Either photo_ids or album_id must be provided.'); + } + }); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->photo_ids = $values['photo_ids'] ?? null; + $album_id = $values['album_id'] ?? null; + $this->album = $album_id !== null ? Album::findOrFail($album_id) : null; + $this->force = isset($values['force']) && static::toBoolean($values['force']); + } + + /** + * @return string[]|null + */ + public function photoIds(): ?array + { + return $this->photo_ids; + } + + public function album(): ?Album + { + return $this->album; + } + + public function force(): bool + { + return $this->force; + } +} diff --git a/app/Http/Requests/Face/ToggleDismissedRequest.php b/app/Http/Requests/Face/ToggleDismissedRequest.php new file mode 100644 index 00000000000..632366e6f07 --- /dev/null +++ b/app/Http/Requests/Face/ToggleDismissedRequest.php @@ -0,0 +1,52 @@ +may_administrate === true) || $this->face->photo->owner_id === $user?->id; + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face = Face::with('photo')->findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Face/UnclusterFacesRequest.php b/app/Http/Requests/Face/UnclusterFacesRequest.php new file mode 100644 index 00000000000..e67d7b1b82f --- /dev/null +++ b/app/Http/Requests/Face/UnclusterFacesRequest.php @@ -0,0 +1,37 @@ + ['required', 'array', 'min:1'], + 'face_ids.*' => ['required', 'string', 'exists:faces,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->face_ids = $values['face_ids']; + } +} diff --git a/app/Http/Requests/Maintenance/ResetStuckFacesRequest.php b/app/Http/Requests/Maintenance/ResetStuckFacesRequest.php new file mode 100644 index 00000000000..81aeec5e7b4 --- /dev/null +++ b/app/Http/Requests/Maintenance/ResetStuckFacesRequest.php @@ -0,0 +1,54 @@ + 'sometimes|integer|min:1', + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->older_than_minutes = isset($values['older_than_minutes']) ? (int) $values['older_than_minutes'] : 60; + } + + public function olderThanMinutes(): int + { + return $this->older_than_minutes; + } +} diff --git a/app/Http/Requests/Person/ClaimPersonRequest.php b/app/Http/Requests/Person/ClaimPersonRequest.php new file mode 100644 index 00000000000..1b6eab3b088 --- /dev/null +++ b/app/Http/Requests/Person/ClaimPersonRequest.php @@ -0,0 +1,71 @@ +may_administrate) { + // Admin can always claim (force-claim) + return true; + } + + // Non-admin: check if user claims are allowed by the administrator + return app(ConfigManager::class)->getValueAsBool('ai_vision_face_allow_user_claim'); + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/DestroyPersonRequest.php b/app/Http/Requests/Person/DestroyPersonRequest.php new file mode 100644 index 00000000000..9b322f22add --- /dev/null +++ b/app/Http/Requests/Person/DestroyPersonRequest.php @@ -0,0 +1,41 @@ + ['required', 'string', 'exists:persons,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person_id = $values['person_id']; + } + + public function personId(): string + { + return $this->person_id; + } +} diff --git a/app/Http/Requests/Person/GetAlbumPersonsRequest.php b/app/Http/Requests/Person/GetAlbumPersonsRequest.php new file mode 100644 index 00000000000..d2a3b45ff75 --- /dev/null +++ b/app/Http/Requests/Person/GetAlbumPersonsRequest.php @@ -0,0 +1,67 @@ +album]); + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + RequestAttribute::ALBUM_ID_ATTRIBUTE => ['required', new AlbumIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + // Load album without unnecessary relations for this request + $this->album = Album::without([ + 'cover', 'cover.size_variants', + 'min_privilege_cover', 'min_privilege_cover.size_variants', + 'max_privilege_cover', 'max_privilege_cover.size_variants', + 'thumb', + 'owner', + 'statistics', + ])->find($values[RequestAttribute::ALBUM_ID_ATTRIBUTE]); + + // If not found, throw ModelNotFoundException + $this->album ??= throw new ModelNotFoundException(); + } +} diff --git a/app/Http/Requests/Person/ListPersonsRequest.php b/app/Http/Requests/Person/ListPersonsRequest.php new file mode 100644 index 00000000000..5bb5b605844 --- /dev/null +++ b/app/Http/Requests/Person/ListPersonsRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'exists:persons,id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->source_person_id = $values['source_person_id']; + } + + public function sourcePersonId(): string + { + return $this->source_person_id; + } +} diff --git a/app/Http/Requests/Person/SelfieClaimRequest.php b/app/Http/Requests/Person/SelfieClaimRequest.php new file mode 100644 index 00000000000..fb87105fd70 --- /dev/null +++ b/app/Http/Requests/Person/SelfieClaimRequest.php @@ -0,0 +1,44 @@ + ['required', 'file', 'image', 'max:10240'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + /** @var UploadedFile $selfie */ + $selfie = $files['selfie']; + $this->selfie = $selfie; + } + + public function selfie(): UploadedFile + { + return $this->selfie; + } +} diff --git a/app/Http/Requests/Person/ShowPersonRequest.php b/app/Http/Requests/Person/ShowPersonRequest.php new file mode 100644 index 00000000000..e52c7b42527 --- /dev/null +++ b/app/Http/Requests/Person/ShowPersonRequest.php @@ -0,0 +1,45 @@ +person]); + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/StorePersonRequest.php b/app/Http/Requests/Person/StorePersonRequest.php new file mode 100644 index 00000000000..1ebf1e5cb50 --- /dev/null +++ b/app/Http/Requests/Person/StorePersonRequest.php @@ -0,0 +1,49 @@ + ['required', 'string', 'max:255'], + 'user_id' => ['nullable', 'integer', 'exists:users,id', 'unique:persons,user_id'], + ]; + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->name = $values['name']; + $this->user_id = isset($values['user_id']) ? (int) $values['user_id'] : null; + } + + public function name(): string + { + return $this->name; + } + + public function userId(): ?int + { + return $this->user_id; + } +} diff --git a/app/Http/Requests/Person/UnclaimPersonRequest.php b/app/Http/Requests/Person/UnclaimPersonRequest.php new file mode 100644 index 00000000000..18b61843810 --- /dev/null +++ b/app/Http/Requests/Person/UnclaimPersonRequest.php @@ -0,0 +1,65 @@ +may_administrate || $this->person->user_id === $user->id; + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + ]; + } + + /** + * {@inheritDoc} + */ + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + } +} diff --git a/app/Http/Requests/Person/UpdatePersonRequest.php b/app/Http/Requests/Person/UpdatePersonRequest.php new file mode 100644 index 00000000000..4b9741e139b --- /dev/null +++ b/app/Http/Requests/Person/UpdatePersonRequest.php @@ -0,0 +1,71 @@ +is_searchable !== null && + !Gate::check(AiVisionPolicy::CAN_CHANGE_PERSON_SEARCHABILITY, [Person::class, $this->person])) { + return false; + } + + return true; + } + + public function rules(): array + { + return [ + 'id' => ['required', new RandomIDRule(false)], + 'name' => ['sometimes', 'string', 'max:255'], + 'is_searchable' => ['sometimes', 'boolean'], + ]; + } + + protected function prepareForValidation(): void + { + /** @disregard */ + $this->merge(['id' => $this->route('id')]); + } + + protected function processValidatedValues(array $values, array $files): void + { + $this->person = Person::findOrFail($values['id']); + $this->name = $values['name'] ?? null; + $this->is_searchable = isset($values['is_searchable']) ? static::toBoolean($values['is_searchable']) : null; + } + + public function name(): ?string + { + return $this->name; + } + + public function isSearchable(): ?bool + { + return $this->is_searchable; + } +} diff --git a/app/Http/Requests/Traits/HasFaceTrait.php b/app/Http/Requests/Traits/HasFaceTrait.php new file mode 100644 index 00000000000..76000a122f6 --- /dev/null +++ b/app/Http/Requests/Traits/HasFaceTrait.php @@ -0,0 +1,21 @@ +face; + } +} diff --git a/app/Http/Requests/Traits/HasPersonTrait.php b/app/Http/Requests/Traits/HasPersonTrait.php new file mode 100644 index 00000000000..93822fd8800 --- /dev/null +++ b/app/Http/Requests/Traits/HasPersonTrait.php @@ -0,0 +1,21 @@ +person; + } +} diff --git a/app/Http/Resources/Collections/PaginatedClustersResource.php b/app/Http/Resources/Collections/PaginatedClustersResource.php new file mode 100644 index 00000000000..626784d50e2 --- /dev/null +++ b/app/Http/Resources/Collections/PaginatedClustersResource.php @@ -0,0 +1,41 @@ + */ + #[LiteralTypeScriptType('App.Http.Resources.Models.ClusterPreviewResource[]')] + public Collection $data; + + public int $current_page; + public int $last_page; + public int $per_page; + public int $total; + + /** + * @param LengthAwarePaginator $paginated_clusters + */ + public function __construct(LengthAwarePaginator $paginated_clusters) + { + $this->data = collect($paginated_clusters->items()); + $this->current_page = $paginated_clusters->currentPage(); + $this->last_page = $paginated_clusters->lastPage(); + $this->per_page = $paginated_clusters->perPage(); + $this->total = $paginated_clusters->total(); + } +} diff --git a/app/Http/Resources/Collections/PaginatedPersonsResource.php b/app/Http/Resources/Collections/PaginatedPersonsResource.php new file mode 100644 index 00000000000..d4b565d4db0 --- /dev/null +++ b/app/Http/Resources/Collections/PaginatedPersonsResource.php @@ -0,0 +1,43 @@ + */ + #[LiteralTypeScriptType('App.Http.Resources.Models.PersonResource[]')] + public Collection $persons; + + public int $current_page; + public int $last_page; + public int $per_page; + public int $total; + + /** + * @param ?LengthAwarePaginator<\App\Models\Person> $paginated_persons + */ + public function __construct(?LengthAwarePaginator $paginated_persons) + { + $this->persons = collect($paginated_persons?->items() ?? []) + ->map(fn ($person) => PersonResource::fromModel($person)); + + $this->current_page = $paginated_persons?->currentPage() ?? 1; + $this->last_page = $paginated_persons?->lastPage() ?? 1; + $this->per_page = $paginated_persons?->perPage() ?? 0; + $this->total = $paginated_persons?->total() ?? 0; + } +} diff --git a/app/Http/Resources/Models/ClusterPreviewResource.php b/app/Http/Resources/Models/ClusterPreviewResource.php new file mode 100644 index 00000000000..913384f8dd9 --- /dev/null +++ b/app/Http/Resources/Models/ClusterPreviewResource.php @@ -0,0 +1,34 @@ + Up to 5 sample crop URLs for preview thumbnails. */ + #[LiteralTypeScriptType('string[]')] + public array $sample_crop_urls; + + public function __construct(int $cluster_label, int $face_count, array $sample_crop_urls) + { + $this->cluster_label = $cluster_label; + $this->face_count = $face_count; + $this->sample_crop_urls = $sample_crop_urls; + } +} diff --git a/app/Http/Resources/Models/FaceResource.php b/app/Http/Resources/Models/FaceResource.php new file mode 100644 index 00000000000..80c73b7a177 --- /dev/null +++ b/app/Http/Resources/Models/FaceResource.php @@ -0,0 +1,58 @@ +id = $face->id; + $this->photo_id = $face->photo_id; + $this->person_id = $face->person_id; + $this->x = $face->x; + $this->y = $face->y; + $this->width = $face->width; + $this->height = $face->height; + $this->confidence = $face->confidence; + $this->laplacian_variance = $face->laplacian_variance; + $this->is_dismissed = $face->is_dismissed; + $this->crop_url = $face->crop_url; + $this->person_name = $face->person?->name; + $this->suggestions = $face->suggestions + ->map(fn (FaceSuggestion $s) => new FaceSuggestionResource($s)) + ->values() + ->all(); + } + + public static function fromModel(Face $face): self + { + return new self($face); + } +} diff --git a/app/Http/Resources/Models/FaceSuggestionResource.php b/app/Http/Resources/Models/FaceSuggestionResource.php new file mode 100644 index 00000000000..44e32375dd6 --- /dev/null +++ b/app/Http/Resources/Models/FaceSuggestionResource.php @@ -0,0 +1,30 @@ +suggested_face_id = $suggestion->suggested_face_id; + $this->crop_url = $suggestion->suggestedFace->crop_url ?? null; + $this->person_name = $suggestion->suggestedFace->person?->name ?? null; + $this->confidence = $suggestion->confidence; + } +} diff --git a/app/Http/Resources/Models/PersonResource.php b/app/Http/Resources/Models/PersonResource.php new file mode 100644 index 00000000000..88ac6f628ec --- /dev/null +++ b/app/Http/Resources/Models/PersonResource.php @@ -0,0 +1,66 @@ +id = $person->id; + $this->name = $person->name; + $this->user_id = $person->user_id; + $this->is_searchable = $person->is_searchable; + $this->representative_face_id = $person->representative_face_id; + $this->face_count = $person->faces()->count(); + $this->photo_count = $person->faces()->distinct('photo_id')->count('photo_id'); + + // Representative crop: prefer the explicitly pinned face's crop, + // fall back to the highest-confidence non-dismissed face with a crop. + $crop_token = null; + if ($person->representative_face_id !== null) { + $crop_token = $person->faces() + ->where('id', '=', $person->representative_face_id) + ->whereNotNull('crop_token') + ->value('crop_token'); + } + + if ($crop_token === null) { + $crop_token = $person->faces() + ->where('is_dismissed', '=', false) + ->whereNotNull('crop_token') + ->orderByDesc('confidence') + ->value('crop_token'); + } + + if ($crop_token !== null) { + $this->representative_crop_url = 'uploads/faces/' . substr($crop_token, 0, 2) . '/' . substr($crop_token, 2, 2) . '/' . $crop_token . '.jpg'; + } else { + $this->representative_crop_url = null; + } + } + + public static function fromModel(Person $person): self + { + return new self($person); + } +} diff --git a/app/Http/Resources/Models/PhotoResource.php b/app/Http/Resources/Models/PhotoResource.php index ac60406d527..947f0825b11 100644 --- a/app/Http/Resources/Models/PhotoResource.php +++ b/app/Http/Resources/Models/PhotoResource.php @@ -8,10 +8,12 @@ namespace App\Http\Resources\Models; +use App\Enum\FacePermissionMode; use App\Enum\LicenseType; use App\Http\Resources\Models\Utils\PreComputedPhotoData; use App\Http\Resources\Models\Utils\PreformattedPhotoData; use App\Http\Resources\Models\Utils\TimelineData; +use App\Models\Face; use App\Models\Photo; use App\Policies\PhotoPolicy; use Illuminate\Support\Carbon; @@ -53,6 +55,9 @@ class PhotoResource extends Data public ?PhotoStatisticsResource $statistics = null; public ?PhotoRatingResource $rating = null; + /** @var FaceResource[] */ + public array $faces = []; + public int $hidden_face_count = 0; public function __construct(Photo $photo, ?string $album_id, bool $should_downgrade_size_variants) { @@ -95,6 +100,60 @@ public function __construct(Photo $photo, ?string $album_id, bool $should_downgr request()->configs(), ); } + + $this->buildFaceData($photo); + } + + /** + * Populate faces[] and hidden_face_count from the photo's detected faces. + * + * Only runs when ai_vision_face_enabled is on and the viewer has permission + * to see face overlays per the ai_vision_face_permission_mode matrix. + * Non-searchable persons are hidden from viewers who are not the linked user; + * they are counted in hidden_face_count instead. + */ + private function buildFaceData(Photo $photo): void + { + if (!request()->configs()->getValueAsBool('ai_vision_enabled') || + !request()->configs()->getValueAsBool('ai_vision_face_enabled')) { + return; + } + + $user = Auth::user(); + $is_admin = $user?->may_administrate === true; + + // Check per-mode overlay visibility (View face overlays row in permission matrix). + $mode = request()->configs()->getValueAsEnum('ai_vision_face_permission_mode', FacePermissionMode::class) ?? FacePermissionMode::RESTRICTED; + $can_view_overlays = $is_admin || match ($mode) { + FacePermissionMode::PUBLIC => true, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING, + FacePermissionMode::RESTRICTED => $user !== null && $photo->owner_id === $user->id, + }; + + if (!$can_view_overlays) { + return; + } + + foreach ($photo->faces as $face) { + /** @var Face $face */ + if ($face->is_dismissed) { + continue; + } + + // Unassigned face or searchable person: always include. + if ($face->person_id === null || ($face->person !== null && $face->person->is_searchable)) { + $this->faces[] = new FaceResource($face); + continue; + } + + // Non-searchable person: visible only to the linked user or admin. + if ($is_admin || ($user !== null && $face->person?->user_id === $user->id)) { + $this->faces[] = new FaceResource($face); + } else { + $this->hidden_face_count++; + } + } } /** diff --git a/app/Http/Resources/Rights/ModulesRightsResource.php b/app/Http/Resources/Rights/ModulesRightsResource.php index c9beab21c3a..d58fd713bfb 100644 --- a/app/Http/Resources/Rights/ModulesRightsResource.php +++ b/app/Http/Resources/Rights/ModulesRightsResource.php @@ -32,6 +32,9 @@ class ModulesRightsResource extends Data public bool $is_mod_renamer_enabled = false; public bool $is_mod_webshop_enabled = false; public bool $is_mod_webhook_enabled = false; + public bool $is_ai_vision_enabled = false; + public bool $is_face_overlay_enabled = true; + public string $face_overlay_default_visibility = 'visible'; public bool $is_contact_enabled = false; public int $messages_count = 0; @@ -47,6 +50,9 @@ public function __construct() $this->is_mod_renamer_enabled = $this->isRenamerEnabled(); $this->is_mod_webshop_enabled = $this->isWebshopEnabled(); $this->is_mod_webhook_enabled = $this->isWebhookEnabled(); + $this->is_ai_vision_enabled = $this->isAiVisionEnabled($is_logged_in); + $this->is_face_overlay_enabled = request()->configs()->getValueAsBool('ai_vision_face_overlay_enabled'); + $this->face_overlay_default_visibility = request()->configs()->getValueAsString('ai_vision_face_overlay_default_visibility') ?: 'visible'; $this->isContactEnabled(); } @@ -208,6 +214,22 @@ private function isWebhookEnabled(): bool return Auth::user()?->may_administrate === true; } + /** + * Check if AI Vision face detection is enabled and accessible to the current user. + * + * @param bool $is_logged_in + * + * @return bool true if AI Vision is enabled and accessible, false otherwise + */ + private function isAiVisionEnabled(bool $is_logged_in): bool + { + if (!$is_logged_in) { + return false; + } + + return request()->configs()->getValueAsBool('ai_vision_enabled'); + } + /** * Check if contact is enabled and set the messages count. * diff --git a/app/Jobs/DeleteFaceEmbeddingsJob.php b/app/Jobs/DeleteFaceEmbeddingsJob.php new file mode 100644 index 00000000000..9eb6bee7529 --- /dev/null +++ b/app/Jobs/DeleteFaceEmbeddingsJob.php @@ -0,0 +1,63 @@ + */ + public array $face_ids; + + /** + * @param list $face_ids + */ + public function __construct(array $face_ids) + { + $this->face_ids = $face_ids; + } + + public function handle(FacialRecognitionService $facial_recognition_service): void + { + if ($this->face_ids === []) { + return; + } + + if (!$facial_recognition_service->isConfigured()) { + Log::warning('DeleteFaceEmbeddingsJob: AI Vision service not configured.'); + + return; + } + + try { + $response = $facial_recognition_service->deleteEmbeddings($this->face_ids); + + if (!$response->successful()) { + Log::warning('DeleteFaceEmbeddingsJob: /embeddings DELETE returned HTTP ' . $response->status() . '.', ['face_ids' => $this->face_ids]); + } + } catch (\Exception $e) { + Log::warning('DeleteFaceEmbeddingsJob: request failed: ' . $e->getMessage(), ['face_ids' => $this->face_ids]); + } + } +} diff --git a/app/Jobs/DispatchFaceScanJob.php b/app/Jobs/DispatchFaceScanJob.php new file mode 100644 index 00000000000..a231a5e2b30 --- /dev/null +++ b/app/Jobs/DispatchFaceScanJob.php @@ -0,0 +1,84 @@ +photo_id = $photo_id; + } + + /** + * Execute the job: send the photo to the AI Vision service. + */ + public function handle(FacialRecognitionService $facial_recognition_service): void + { + $photo = Photo::with('size_variants')->find($this->photo_id); + + if ($photo === null) { + Log::warning("DispatchFaceScanJob: photo {$this->photo_id} not found, skipping."); + + return; + } + + if (!$facial_recognition_service->isConfigured()) { + Log::warning("DispatchFaceScanJob: AI Vision service not configured, marking photo {$this->photo_id} as failed."); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + + return; + } + + $original = $photo->size_variants->getOriginal(); + + if ($original === null) { + Log::warning("DispatchFaceScanJob: no original size variant for photo {$this->photo_id}, marking as failed."); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + + return; + } + + try { + $response = $facial_recognition_service->detectFaces($this->photo_id, $original->short_path); + + if (!$response->successful()) { + Log::warning("DispatchFaceScanJob: /detect returned HTTP {$response->status()} for photo {$this->photo_id}.", ['response' => $response->json()]); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + } + } catch (\Exception $e) { + Log::warning("DispatchFaceScanJob: /detect request failed for photo {$this->photo_id}: " . $e->getMessage()); + $photo->face_scan_status = FaceScanStatus::FAILED; + $photo->save(); + } + } +} diff --git a/app/Models/Face.php b/app/Models/Face.php new file mode 100644 index 00000000000..b0a3475e428 --- /dev/null +++ b/app/Models/Face.php @@ -0,0 +1,148 @@ + $suggestions + * @property string|null $crop_url + */ +class Face extends Model +{ + use HasFactory; + /** @phpstan-use HasRandomIDAndLegacyTimeBasedID */ + use HasRandomIDAndLegacyTimeBasedID; + use ThrowsConsistentExceptions; + use ToArrayThrowsNotImplemented; + + /** + * @var string The type of the primary key + */ + protected $keyType = 'string'; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'photo_id', + 'person_id', + 'x', + 'y', + 'width', + 'height', + 'confidence', + 'laplacian_variance', + 'crop_token', + 'is_dismissed', + 'cluster_label', + ]; + + /** + * @var list + */ + protected $appends = [ + 'crop_url', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'x' => 'float', + 'y' => 'float', + 'width' => 'float', + 'height' => 'float', + 'confidence' => 'float', + 'laplacian_variance' => 'float', + 'is_dismissed' => 'boolean', + 'cluster_label' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * Compute the crop URL from the crop_token. + */ + public function getCropUrlAttribute(): ?string + { + if ($this->crop_token === null) { + return null; + } + $tok = $this->crop_token; + + return 'uploads/faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg'; + } + + /** + * Return the photo this face belongs to. + * + * @return BelongsTo + */ + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class, 'photo_id', 'id'); + } + + /** + * Return the person associated with this face. + * + * @return BelongsTo + */ + public function person(): BelongsTo + { + return $this->belongsTo(Person::class, 'person_id', 'id'); + } + + /** + * Return the suggestions for this face (pre-computed similar faces). + * + * @return HasMany + */ + public function suggestions(): HasMany + { + return $this->hasMany(FaceSuggestion::class, 'face_id', 'id'); + } +} diff --git a/app/Models/FaceSuggestion.php b/app/Models/FaceSuggestion.php new file mode 100644 index 00000000000..b276162f3c7 --- /dev/null +++ b/app/Models/FaceSuggestion.php @@ -0,0 +1,72 @@ + + */ + protected $fillable = [ + 'face_id', + 'suggested_face_id', + 'confidence', + ]; + + /** + * @var array + */ + protected $casts = [ + 'confidence' => 'float', + ]; + + /** + * Return the face this suggestion belongs to. + * + * @return BelongsTo + */ + public function face(): BelongsTo + { + return $this->belongsTo(Face::class, 'face_id', 'id'); + } + + /** + * Return the suggested face. + * + * @return BelongsTo + */ + public function suggestedFace(): BelongsTo + { + return $this->belongsTo(Face::class, 'suggested_face_id', 'id'); + } +} diff --git a/app/Models/Person.php b/app/Models/Person.php new file mode 100644 index 00000000000..b03657fa42c --- /dev/null +++ b/app/Models/Person.php @@ -0,0 +1,120 @@ + $faces + * @property Face|null $representativeFace + */ +class Person extends Model +{ + use HasFactory; + /** @phpstan-use HasRandomIDAndLegacyTimeBasedID */ + use HasRandomIDAndLegacyTimeBasedID; + use ThrowsConsistentExceptions; + use ToArrayThrowsNotImplemented; + + protected $table = 'persons'; + + /** + * @var string The type of the primary key + */ + protected $keyType = 'string'; + + /** + * Indicates if the model's primary key is auto-incrementing. + * + * @var bool + */ + public $incrementing = false; + + /** + * @var list + */ + protected $fillable = [ + 'name', + 'user_id', + 'is_searchable', + 'representative_face_id', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + 'is_searchable' => 'boolean', + 'user_id' => 'integer', + 'representative_face_id' => 'string', + ]; + } + + /** + * Return the user linked to this person. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } + + /** + * Return the faces associated with this person. + * + * @return HasMany + */ + public function faces(): HasMany + { + return $this->hasMany(Face::class, 'person_id', 'id'); + } + + /** + * Return the representative face for this person (nullable). + * + * @return BelongsTo + */ + public function representativeFace(): BelongsTo + { + return $this->belongsTo(Face::class, 'representative_face_id', 'id'); + } + + /** + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeSearchable($query) + { + return $query->where('is_searchable', '=', true); + } +} diff --git a/app/Models/Photo.php b/app/Models/Photo.php index ab31e9b0e78..8384445a19a 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -13,6 +13,7 @@ use App\Casts\MustNotSetCast; use App\Constants\PhotoAlbum as PA; use App\Contracts\Models\HasUTCBasedTimes; +use App\Enum\FaceScanStatus; use App\Enum\LicenseType; use App\Enum\SmartAlbumType; use App\Enum\StorageDiskType; @@ -170,6 +171,7 @@ class Photo extends Model implements HasUTCBasedTimes 'altitude' => 'float', 'img_direction' => 'float', 'rating_avg' => 'decimal:4', + 'face_scan_status' => FaceScanStatus::class, ]; /** @@ -272,6 +274,16 @@ public function palette(): HasOne return $this->hasOne(Palette::class, 'photo_id', 'id'); } + /** + * Return the faces detected in this photo. + * + * @return HasMany + */ + public function faces(): HasMany + { + return $this->hasMany(Face::class, 'photo_id', 'id'); + } + /** * Returns the relationship between a tag and all photos with whom * this tag is attached. diff --git a/app/Models/User.php b/app/Models/User.php index fd85d664cf5..1091621e418 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -181,6 +181,16 @@ public function oauthCredentials(): HasMany return $this->hasMany(OauthCredential::class, 'user_id', 'id'); } + /** + * Return the Person linked to this user (1-1). + * + * @return \Illuminate\Database\Eloquent\Relations\HasOne + */ + public function person(): \Illuminate\Database\Eloquent\Relations\HasOne + { + return $this->hasOne(Person::class, 'user_id', 'id'); + } + /** * Used by Larapass. * diff --git a/app/Observers/PhotoObserver.php b/app/Observers/PhotoObserver.php new file mode 100644 index 00000000000..1fa7ab31beb --- /dev/null +++ b/app/Observers/PhotoObserver.php @@ -0,0 +1,34 @@ +faces()->pluck('id')->all(); + if ($face_ids !== []) { + DeleteFaceEmbeddingsJob::dispatch($face_ids); + } + } +} diff --git a/app/Policies/AiVisionPolicy.php b/app/Policies/AiVisionPolicy.php new file mode 100644 index 00000000000..b97a38fcd7e --- /dev/null +++ b/app/Policies/AiVisionPolicy.php @@ -0,0 +1,172 @@ +getValueAsEnum('ai_vision_face_permission_mode', FacePermissionMode::class) ?? FacePermissionMode::RESTRICTED; + } + + /** + * View People page / list persons. + * public: guest; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canViewPeople(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => true, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * View a specific Person record. + * Requires canViewPeople access, plus the person must be searchable or linked to the user. + * Admins always pass via before(). + */ + public function canShowPerson(?User $user, Person $person): bool + { + if (!$this->canViewPeople($user)) { + return false; + } + + return $person->is_searchable || $person->user_id === $user?->id; + } + + /** + * Create/edit Person. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canEditPerson(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * Assign face to person. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canAssignFace(?User $user): bool + { + // Same rules as edit + return $this->canEditPerson($user); + } + + /** + * Trigger face scan on photos. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: owner+admin. + */ + public function canTriggerScan(?User $user): bool + { + $mode = $this->getMode(); + + return match ($mode) { + FacePermissionMode::PUBLIC => $user !== null, + FacePermissionMode::PRIVATE => $user !== null, + FacePermissionMode::PRIVACY_PRESERVING => false, // admin handled by before() + FacePermissionMode::RESTRICTED => false, // admin handled by before() + }; + } + + /** + * Claim a person (link to own user account). + * All modes: logged users (admins always pass via before()). + */ + public function canClaimPerson(?User $user): bool + { + return $user !== null; + } + + /** + * Merge two persons. + * public: logged; private: logged; privacy-preserving: owner+admin; restricted: admin only. + */ + public function canMergePersons(?User $user): bool + { + // Same rules as edit + return $this->canEditPerson($user); + } + + /** + * Dismiss / undismiss a face. + * photo owner or admin (handled at controller level; this gate is for admin check only). + */ + public function canDismissFace(?User $user): bool + { + return $user !== null; + } + + /** + * Check if user can manage a specific person (delete, update searchability). + * Only the linked user or admin can manage. + */ + public function canManagePerson(?User $user, Person $person): bool + { + if ($user === null) { + return false; + } + + return $person->user_id === $user->id; + } + + /** + * Change the is_searchable flag of a Person. + * Only the person's linked user or admin (via before()) may toggle it. + */ + public function canChangePersonSearchability(?User $user, Person $person): bool + { + return $person->user_id === $user?->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bed6d15ed2..7abf866a099 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -27,6 +27,8 @@ use App\Metadata\Versions\Remote\GitCommits; use App\Metadata\Versions\Remote\GitTags; use App\Models\Configs; +use App\Models\Photo; +use App\Observers\PhotoObserver; use App\Policies\AlbumQueryPolicy; use App\Policies\PhotoQueryPolicy; use App\Policies\SettingsPolicy; @@ -111,6 +113,7 @@ public function boot() $this->registerHttpAndResponseConfiguration(); $this->registerStreamFilters(); $this->registerOctaneSettings(); + Photo::observe(PhotoObserver::class); } /** diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 8ff4fff84ba..cd6e4b50dd2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -13,11 +13,14 @@ use App\Models\Album; use App\Models\Configs; use App\Models\Extensions\BaseAlbum; +use App\Models\Face; use App\Models\LiveMetrics; +use App\Models\Person; use App\Models\Photo; use App\Models\Tag; use App\Models\User; use App\Models\UserGroup; +use App\Policies\AiVisionPolicy; use App\Policies\AlbumPolicy; use App\Policies\MetricsPolicy; use App\Policies\PhotoPolicy; @@ -57,6 +60,9 @@ class AuthServiceProvider extends ServiceProvider UserGroup::class => UserGroupPolicy::class, Tag::class => TagPolicy::class, + + Person::class => AiVisionPolicy::class, + Face::class => AiVisionPolicy::class, ]; /** diff --git a/app/Repositories/PhotoRepository.php b/app/Repositories/PhotoRepository.php index 5855b0fbeb8..a082cb1e21f 100644 --- a/app/Repositories/PhotoRepository.php +++ b/app/Repositories/PhotoRepository.php @@ -58,7 +58,7 @@ public function getPhotosForAlbumPaginated( ->join(PA::PHOTO_ALBUM, PA::PHOTO_ID, '=', 'photos.id') ->where(PA::ALBUM_ID, '=', $album_id) ->select('photos.*') - ->with(['size_variants', 'tags', 'palette', 'statistics', 'rating']); + ->with(['size_variants', 'tags', 'palette', 'statistics', 'rating', 'faces.person', 'faces.suggestions.suggestedFace.person']); // Apply tag filtering if tag_ids provided and not empty if ($tag_ids !== null && count($tag_ids) > 0) { diff --git a/app/Services/Image/FacialRecognitionService.php b/app/Services/Image/FacialRecognitionService.php new file mode 100644 index 00000000000..054d30749d9 --- /dev/null +++ b/app/Services/Image/FacialRecognitionService.php @@ -0,0 +1,148 @@ +service_url = config('features.ai-vision.face-url', ''); + $this->api_key = config('features.ai-vision.face-api-key', ''); + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return $this->service_url !== ''; + } + + /** + * Match a selfie image against stored face embeddings. + * + * @param string $file_path The path to the selfie image file + * @param string $file_name The original filename + * + * @return array{matches: array}|null + * + * @throws \Exception When the HTTP request fails + */ + public function matchSelfie(string $file_path, string $file_name): ?array + { + if (!$this->isConfigured()) { + Log::warning('FacialRecognitionService: matchSelfie called but service is not configured.'); + + return null; + } + + $response = Http::withHeaders(['X-API-Key' => $this->api_key]) + ->attach('image', file_get_contents($file_path), $file_name) + ->post($this->service_url . '/match'); + + return $response->successful() ? $response->json() : null; + } + + /** + * Detect faces in a photo. + * + * @param string $photo_id The photo ID + * @param string $photo_path The path to the photo + * + * @return Response + * + * @throws \Exception When the HTTP request fails + */ + public function detectFaces(string $photo_id, string $photo_path): Response + { + if (!$this->isConfigured()) { + throw new \RuntimeException('AI Vision service is not configured.'); + } + + $data = [ + 'photo_id' => $photo_id, + 'photo_path' => $photo_path, + ]; + + return Http::withHeaders(['X-API-Key' => $this->api_key]) + ->post($this->service_url . '/detect', $data); + } + + /** + * Delete face embeddings from the AI Vision service. + * + * @param list $face_ids The face IDs to delete + * + * @return Response + * + * @throws \Exception When the HTTP request fails + */ + public function deleteEmbeddings(array $face_ids): Response + { + if (!$this->isConfigured()) { + throw new \RuntimeException('AI Vision service is not configured.'); + } + + return Http::withHeaders(['X-API-Key' => $this->api_key]) + ->delete($this->service_url . '/embeddings', ['face_ids' => $face_ids]); + } + + /** + * Check the health status of the AI Vision service. + * + * @return array{status: string, model_loaded: bool, embedding_count: int}|null + * + * @throws \Exception When the HTTP request fails + */ + public function checkHealth(): ?array + { + if (!$this->isConfigured()) { + Log::warning('FacialRecognitionService: checkHealth called but service is not configured.'); + + return null; + } + + $response = Http::withHeaders(['X-API-Key' => $this->api_key]) + ->get($this->service_url . '/health'); + + return $response->successful() ? $response->json() : null; + } + + /** + * Export all face embeddings with metadata for synchronization. + * + * @return array{count: int, embeddings: array}|null + * + * @throws \Exception When the HTTP request fails + */ + public function syncFaceEmbeddings(): ?array + { + if (!$this->isConfigured()) { + Log::warning('FacialRecognitionService: syncFaceEmbeddings called but service is not configured.'); + + return null; + } + + $response = Http::withHeaders(['X-API-Key' => $this->api_key]) + ->get($this->service_url . '/embeddings/export'); + + return $response->successful() ? $response->json() : null; + } +} diff --git a/config/features.php b/config/features.php index cda39c79069..21170c18346 100644 --- a/config/features.php +++ b/config/features.php @@ -185,4 +185,19 @@ | missing macros and test failures. */ 'populate-request-macros' => (bool) env('POPULATE_REQUEST_MACROS', false), + + /* + |-------------------------------------------------------------------------- + | AI Vision service integration. + |-------------------------------------------------------------------------- + | + | Infrastructure keys for the external AI Vision (facial recognition) service. + | These are NOT stored in the configs table to avoid exposing the service URL + | or shared API key through the admin settings UI. + */ + 'ai-vision' => [ + 'face-url' => env('AI_VISION_FACE_URL', ''), + 'face-api-key' => env('AI_VISION_FACE_API_KEY', ''), + 'face-rescan-iou-threshold' => (float) env('AI_VISION_FACE_RESCAN_IOU_THRESHOLD', 0.3), + ], ]; \ No newline at end of file diff --git a/database/factories/FaceFactory.php b/database/factories/FaceFactory.php new file mode 100644 index 00000000000..447816d9e9f --- /dev/null +++ b/database/factories/FaceFactory.php @@ -0,0 +1,72 @@ + + */ +class FaceFactory extends Factory +{ + protected $model = Face::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'photo_id' => Photo::factory(), + 'person_id' => null, + 'x' => fake()->randomFloat(4, 0.0, 0.8), + 'y' => fake()->randomFloat(4, 0.0, 0.8), + 'width' => fake()->randomFloat(4, 0.05, 0.2), + 'height' => fake()->randomFloat(4, 0.05, 0.2), + 'confidence' => fake()->randomFloat(4, 0.5, 1.0), + 'crop_token' => Str::random(24), + 'is_dismissed' => false, + 'cluster_label' => null, + ]; + } + + public function for_photo(Photo $photo): self + { + return $this->state(fn () => ['photo_id' => $photo->id]); + } + + public function for_person(Person $person): self + { + return $this->state(fn () => ['person_id' => $person->id]); + } + + public function dismissed(): self + { + return $this->state(fn () => ['is_dismissed' => true]); + } + + public function without_crop(): self + { + return $this->state(fn () => ['crop_token' => null]); + } + + public function with_cluster(int $label): self + { + return $this->state(fn () => ['cluster_label' => $label]); + } + + public function with_confidence(float $confidence): self + { + return $this->state(fn () => ['confidence' => $confidence]); + } +} diff --git a/database/factories/PersonFactory.php b/database/factories/PersonFactory.php new file mode 100644 index 00000000000..f77c136968c --- /dev/null +++ b/database/factories/PersonFactory.php @@ -0,0 +1,49 @@ + + */ +class PersonFactory extends Factory +{ + protected $model = Person::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'user_id' => null, + 'is_searchable' => true, + 'representative_face_id' => null, + ]; + } + + public function not_searchable(): self + { + return $this->state(fn () => ['is_searchable' => false]); + } + + public function linked_to(User $user): self + { + return $this->state(fn () => ['user_id' => $user->id]); + } + + public function with_name(string $name): self + { + return $this->state(fn () => ['name' => $name]); + } +} diff --git a/database/migrations/2026_03_21_000001_create_persons_table.php b/database/migrations/2026_03_21_000001_create_persons_table.php new file mode 100644 index 00000000000..48ba2e08c1a --- /dev/null +++ b/database/migrations/2026_03_21_000001_create_persons_table.php @@ -0,0 +1,40 @@ +char('id', self::RANDOM_ID_LENGTH)->primary(); + $table->string('name', 255)->nullable(false); + $table->unsignedInteger('user_id')->nullable(true)->unique(); + $table->boolean('is_searchable')->default(true); + $table->timestamps(); + + $table->index('user_id'); + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('persons'); + } +}; diff --git a/database/migrations/2026_03_21_000002_create_faces_table.php b/database/migrations/2026_03_21_000002_create_faces_table.php new file mode 100644 index 00000000000..668066b062e --- /dev/null +++ b/database/migrations/2026_03_21_000002_create_faces_table.php @@ -0,0 +1,68 @@ +char('id', self::RANDOM_ID_LENGTH)->primary(); + $table->char('photo_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->char('person_id', self::RANDOM_ID_LENGTH)->nullable(true); + $table->float('x')->nullable(false); + $table->float('y')->nullable(false); + $table->float('width')->nullable(false); + $table->float('height')->nullable(false); + $table->float('confidence')->nullable(false); + $table->float('laplacian_variance')->default(0.0); + $table->string('crop_token')->nullable(true); + $table->boolean('is_dismissed')->default(false); + $table->timestamps(); + + $table->index('photo_id'); + $table->index('person_id'); + $table->foreign('photo_id')->references('id')->on('photos')->cascadeOnDelete(); + $table->foreign('person_id')->references('id')->on('persons')->nullOnDelete(); + }); + + Schema::create('face_suggestions', function (Blueprint $table) { + $table->char('face_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->char('suggested_face_id', self::RANDOM_ID_LENGTH)->nullable(false); + $table->float('confidence')->nullable(false); + + $table->unique(['face_id', 'suggested_face_id']); + $table->foreign('face_id')->references('id')->on('faces')->cascadeOnDelete(); + $table->foreign('suggested_face_id')->references('id')->on('faces')->cascadeOnDelete(); + }); + + Schema::table('photos', function (Blueprint $table) { + $table->string('face_scan_status', 16)->nullable(true)->after('is_highlighted'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('photos', function (Blueprint $table) { + $table->dropColumn('face_scan_status'); + }); + + Schema::dropIfExists('face_suggestions'); + Schema::dropIfExists('faces'); + } +}; diff --git a/database/migrations/2026_03_21_000003_ai_vision_config.php b/database/migrations/2026_03_21_000003_ai_vision_config.php new file mode 100644 index 00000000000..f30d5b6d355 --- /dev/null +++ b/database/migrations/2026_03_21_000003_ai_vision_config.php @@ -0,0 +1,127 @@ + 'ai_vision_enabled', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable AI Vision features', + 'details' => 'Master toggle for the AI Vision subsystem. When disabled, all AI Vision endpoints and UI elements are inactive.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 10, + ], + [ + 'key' => 'ai_vision_face_enabled', + 'value' => '0', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable facial recognition', + 'details' => 'Enable the facial recognition subsystem. Requires ai_vision_enabled = 1. When disabled, face detection endpoints, People pages, and auto-scan on upload are inactive.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 11, + ], + [ + 'key' => 'ai_vision_face_permission_mode', + 'value' => 'restricted', + 'cat' => self::CAT, + 'type_range' => 'public|private|privacy-preserving|restricted', + 'description' => 'Permission mode for facial recognition features', + 'details' => 'Controls who can view people, face overlays, and manage faces. Options: public, private, privacy-preserving, restricted.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 12, + ], + [ + 'key' => 'ai_vision_face_selfie_confidence_threshold', + 'value' => '0.8', + 'cat' => self::CAT, + 'type_range' => self::STRING, + 'description' => 'Minimum confidence threshold for selfie-based person claim', + 'details' => 'Minimum match confidence score (0.0-1.0) required to automatically link a person via selfie upload.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 13, + ], + [ + 'key' => 'ai_vision_face_person_is_searchable_default', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Default searchability for new persons', + 'details' => 'Default value of the is_searchable flag when a new Person record is created.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 14, + ], + [ + 'key' => 'ai_vision_face_allow_user_claim', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Allow users to claim their person', + 'details' => 'When enabled, regular (non-admin) users may claim a Person record to link it to their account. Admins can always claim/unclaim regardless of this setting.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 15, + ], + [ + 'key' => 'ai_vision_face_scan_batch_size', + 'value' => '200', + 'cat' => self::CAT, + 'type_range' => self::POSITIVE, + 'description' => 'Batch size for bulk face scanning', + 'details' => 'Number of photo IDs dispatched per job chunk when bulk-scanning. Lower values reduce burst queue load.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 16, + ], + [ + 'key' => 'ai_vision_face_overlay_enabled', + 'value' => '1', + 'cat' => self::CAT, + 'type_range' => self::BOOL, + 'description' => 'Enable face overlay on photos', + 'details' => 'Master toggle for face bounding-box overlays. When disabled (0), no face overlays or face circles are shown anywhere in the UI, regardless of detection results.', + 'is_expert' => false, + 'is_secret' => false, + 'level' => 1, + 'order' => 17, + ], + [ + 'key' => 'ai_vision_face_overlay_default_visibility', + 'value' => 'visible', + 'cat' => self::CAT, + 'type_range' => 'visible|hidden', + 'description' => 'Default visibility of face overlay when viewing a photo', + 'details' => 'Sets whether face overlays are shown (visible) or hidden by default when a photo is opened. Users can toggle visibility with the P key.', + 'is_expert' => true, + 'is_secret' => false, + 'level' => 1, + 'order' => 18, + ], + ]; + } +}; diff --git a/database/migrations/2026_03_21_000004_create_ai_vision_category.php b/database/migrations/2026_03_21_000004_create_ai_vision_category.php new file mode 100644 index 00000000000..3240775329f --- /dev/null +++ b/database/migrations/2026_03_21_000004_create_ai_vision_category.php @@ -0,0 +1,37 @@ +insert([ + [ + 'cat' => self::CAT, + 'name' => 'AI Vision', + 'description' => 'This module integrates with an external AI service to provide facial recognition, person management, and automatic face scanning capabilities.', + 'order' => 27, + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('config_categories')->where('cat', self::CAT)->delete(); + } +}; diff --git a/database/migrations/2026_03_21_000005_add_representative_face_id_to_persons.php b/database/migrations/2026_03_21_000005_add_representative_face_id_to_persons.php new file mode 100644 index 00000000000..cdc30c3d820 --- /dev/null +++ b/database/migrations/2026_03_21_000005_add_representative_face_id_to_persons.php @@ -0,0 +1,42 @@ +char('representative_face_id', 24)->nullable()->after('is_searchable'); + $table->foreign('representative_face_id') + ->references('id') + ->on('faces') + ->onDelete('set null'); + }); + } + + public function down(): void + { + Schema::table('persons', function (Blueprint $table): void { + $table->dropForeign(['representative_face_id']); + $table->dropColumn('representative_face_id'); + }); + } +}; diff --git a/database/migrations/2026_03_21_000006_add_cluster_label_to_faces.php b/database/migrations/2026_03_21_000006_add_cluster_label_to_faces.php new file mode 100644 index 00000000000..79d87e305ea --- /dev/null +++ b/database/migrations/2026_03_21_000006_add_cluster_label_to_faces.php @@ -0,0 +1,41 @@ +integer('cluster_label')->nullable()->after('is_dismissed'); + $table->index(['cluster_label', 'person_id', 'is_dismissed'], 'faces_cluster_label_person_id_is_dismissed_index'); + }); + } + + public function down(): void + { + Schema::table('faces', function (Blueprint $table): void { + $table->dropIndex('faces_cluster_label_person_id_is_dismissed_index'); + $table->dropColumn('cluster_label'); + }); + } +}; diff --git a/docker-compose.minimal.yaml b/docker-compose.minimal.yaml index 7949e3dec2d..b9a879f9dc8 100644 --- a/docker-compose.minimal.yaml +++ b/docker-compose.minimal.yaml @@ -46,6 +46,8 @@ x-common-env: &common-env SESSION_DRIVER: "${SESSION_DRIVER:-file}" SESSION_LIFETIME: "${SESSION_LIFETIME:-120}" QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" + AI_VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + AI_VISION_FACE_URL: "http://ai_vision:8000" services: lychee_api: @@ -122,6 +124,37 @@ services: retries: 10 start_period: 10s + ai_vision: + build: + context: ./ai-vision-service + container_name: lychee-ai-vision + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + VISION_FACE_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}" + volumes: + - ./lychee/uploads:/data/photos:ro + - ai_vision_embeddings:/data/embeddings + networks: + - lychee + depends_on: + lychee_api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: lychee: @@ -129,3 +162,6 @@ volumes: mysql: name: lychee_prod_mysql driver: local + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local diff --git a/docker-compose.yaml b/docker-compose.yaml index 0b9eab201da..1748ef1bbcf 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,10 +26,10 @@ x-base-lychee-setup: &base-lychee-setup # There are also other images available which uses nginx as a base instead of FrankenPHP: # - ghcr.io/lycheeorg/lychee:latest-legacy # - ghcr.io/lycheeorg/lychee:edge-legacy - image: ghcr.io/lycheeorg/lychee:latest + # image: ghcr.io/lycheeorg/lychee:latest # The following lines are for development purposes only. - # image: lychee-frankenphp:latest + image: lychee-frankenphp # image: lychee-legacy:latest # build: # context: ./app @@ -219,7 +219,7 @@ x-common-env: &common-env # # For improved reactivity of Lychee we recommend to use a worker. # You can use either 'database' or 'redis' as queue driver (but in the later you need to enable redis). - # QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" + QUEUE_CONNECTION: "${QUEUE_CONNECTION:-database}" # Logging Stuff. # LOG_CHANNEL: "${LOG_CHANNEL:-stack}" @@ -404,6 +404,12 @@ x-common-env: &common-env # PAYPAL_CLIENT_ID= # PAYPAL_SECRET= + ################################################################### + # Facial recognition (requires SE) # + ################################################################### + AI_VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + AI_VISION_FACE_URL: "http://ai_vision:8000" + services: ########################################################################################## # Lychee API Service and frontend. @@ -503,6 +509,22 @@ services: - cache:/data:rw restart: on-failure:5 + phpmyadmin: + image: phpmyadmin + restart: always + ports: + - 8080:80 + environment: + - PMA_HOST=lychee_db + - PMA_PORT=3306 + depends_on: + lychee_db: + condition: service_healthy + networks: + - lychee + profiles: + - phpmyadmin + lychee_db: image: mariadb:10 security_opt: @@ -549,6 +571,43 @@ services: retries: 10 start_period: 10s + ai_vision: + expose: + - "${APP_PORT_AI_FACE:-8001}" + ports: + - "${APP_PORT_AI_FACE:-8001}:8000" + # build: + # context: ./ai-vision-service + # container_name: lychee-ai-vision + image: lychee-ai-vision + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + VISION_FACE_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}" + volumes: + - ./lychee/uploads:/data/photos:ro + - ai_vision_embeddings:/data/embeddings + networks: + - lychee + # depends_on: + # lychee_api: + # condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + networks: lychee: @@ -559,3 +618,6 @@ volumes: cache: name: lychee_prod_redis driver: local + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local diff --git a/docs/specs/2-how-to/configure-facial-recognition.md b/docs/specs/2-how-to/configure-facial-recognition.md new file mode 100644 index 00000000000..e7d187ff58c --- /dev/null +++ b/docs/specs/2-how-to/configure-facial-recognition.md @@ -0,0 +1,231 @@ +# How-To: Configure Facial Recognition (AI Vision) + +**Author:** Lychee Team +**Last Updated:** 2026-03-22 +**Feature:** 030-ai-vision-service +**Related:** [Feature 030 Spec](../4-architecture/features/030-ai-vision-service/spec.md) + +## Overview + +Lychee's facial recognition feature is powered by a sidecar Python service (`ai-vision-service`). When enabled, Lychee detects faces in photos, groups them into Person profiles, and lets users claim their own profile. This guide covers: + +1. [Prerequisites](#prerequisites) +2. [Docker Compose setup](#docker-compose-setup) +3. [Shared volume configuration](#shared-volume-configuration) +4. [Environment variables](#environment-variables) +5. [Enabling the feature in Lychee admin](#enabling-the-feature-in-lychee-admin) +6. [Permission modes](#permission-modes) +7. [Running a bulk scan](#running-a-bulk-scan) +8. [Service health check](#service-health-check) +9. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +- Docker and Docker Compose v2 +- A working Lychee deployment (see [docker-compose.minimal.yaml](../../../docker-compose.minimal.yaml)) +- A **Supporter Edition (SE)** licence — AI Vision is an SE-only feature + +--- + +## Docker Compose Setup + +Add the `ai_vision` service to your `docker-compose.yaml`. The complete minimal example is in [docker-compose.minimal.yaml](../../../docker-compose.minimal.yaml). The key stanza: + +```yaml +services: + lychee_api: + # ... existing config ... + volumes: + - ./lychee/uploads:/app/public/uploads # Lychee upload directory + + ai_vision: + build: + context: ./ai-vision-service # Build from source, OR use a pre-built image + container_name: lychee-ai-vision + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + VISION_FACE_LYCHEE_API_URL: "http://lychee_api:8000" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + volumes: + - ./lychee/uploads:/data/photos:ro # Shared read-only photos volume + - ai_vision_embeddings:/data/embeddings # Persistent embeddings store + networks: + - lychee + depends_on: + lychee_api: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + +volumes: + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local +``` + +--- + +## Shared Volume Configuration + +The AI Vision service reads photo files directly from the filesystem — no HTTP file transfer. This requires both containers to mount the same upload directory: + +| Container | Mount path | Mode | +|---|---|---| +| `lychee_api` | `/app/public/uploads` | read/write | +| `ai_vision` | `/data/photos` | read-only | +| Host bind mount | `./lychee/uploads` | (source for both) | + +**Critical:** The host path `./lychee/uploads` must be identical for both mounts. If you use an absolute path or a named volume for `lychee_api`'s uploads, apply the same source to `ai_vision`. + +--- + +## Environment Variables + +### Lychee API (`lychee_api` / `lychee_worker`) + +Add these to `x-common-env` or the service's `environment` block: + +| Variable | Description | Example | +|---|---|---| +| `AI_VISION_FACE_URL` | Internal URL of the AI Vision service | `http://lychee-ai-vision:8000` | +| `AI_VISION_FACE_API_KEY` | Shared secret used in both directions: Lychee sends it on scan requests to Python; Python sends it on callback responses to Lychee | `changeme-strong-random-value` | + +> Generate strong secrets with: `openssl rand -hex 32` + +### AI Vision Service (`ai_vision`) + +| Variable | Description | Default | +|---|---|---| +| `VISION_FACE_LYCHEE_API_URL` | Base URL of the Lychee API (for callbacks) | — | +| `VISION_FACE_API_KEY` | Must match `AI_VISION_FACE_API_KEY` in Lychee | — | +| `VISION_FACE_VERIFY_SSL` | Verify SSL certificates when connecting to Lychee. Set to `false` for dev environments with self-signed certificates | `true` | +| `VISION_FACE_PHOTOS_PATH` | Path where photos are mounted inside the container | `/data/photos` | +| `VISION_FACE_STORAGE_PATH` | Path for persisting face embeddings | `/data/embeddings` | + +--- + +## Enabling the Feature in Lychee Admin + +After starting the containers, enable the feature in **Admin → Settings → AI Vision**: + +1. **AI Vision enabled** — master toggle; set to `On`. +2. **Facial recognition enabled** — sub-toggle; set to `On`. +3. Configure optional settings (permission mode, batch size, etc.). + +These settings are only visible on Supporter Edition instances. + +--- + +## Permission Modes + +`ai_vision_face_permission_mode` controls who can view People, face overlays, and perform face management. Choose the mode that matches your deployment scenario. + +| Mode | Best for | +|---|---| +| `public` | Community/open galleries where anyone can browse people | +| `private` | Personal or team galleries — all features require login | +| `privacy-preserving` | Multi-user deployments — users only see their own content | +| `restricted` | High-privacy or admin-controlled deployments | + +**Permission matrix:** + +| Operation | `public` | `private` | `privacy-preserving` | `restricted` | +|---|---|---|---|---| +| View People page | Guest | Logged in | Owner + admin | Admin only | +| View face overlays | Album access | Logged in | Owner + admin | Owner + admin | +| Create / edit Person | Logged in | Logged in | Owner + admin | Admin only | +| Assign face | Logged in | Logged in | Owner + admin | Admin only | +| Trigger scan | Logged in | Logged in | Owner + admin | Owner + admin | +| Claim person (selfie) | Logged in | Logged in | Logged in | Logged in | +| Merge persons | Logged in | Logged in | Owner + admin | Admin only | + +> **Default:** `restricted` — the most conservative option. + +--- + +## Running a Bulk Scan + +After setup, scan your existing photo library for faces: + +**Via the admin UI:** +1. Navigate to **Admin → Maintenance**. +2. Find the **Bulk Face Scan** card and click **Scan all unscanned photos**. + +**Via CLI:** +```bash +php artisan lychee:scan-faces +``` + +Scanning runs asynchronously through the queue. Ensure the `lychee_worker` container is running. Progress is visible in the queue job history. + +--- + +## Service Health Check + +The AI Vision service exposes a `/health` endpoint: + +```bash +# Inside the lychee network, from another container: +curl http://lychee-ai-vision:8000/health + +# From the host (if you expose the port): +curl http://localhost:/health +``` + +A healthy response: +```json +{"status": "ok", "version": "x.y.z"} +``` + +Docker will also report the container's health status — wait for `healthy` before triggering scans: + +```bash +docker compose ps +``` + +--- + +## Troubleshooting + +### AI Vision endpoints return 403 + +- Confirm the Lychee instance is a **Supporter Edition** licence. +- Check that `ai_vision_enabled = 1` and `ai_vision_face_enabled = 1` in admin settings. + +### Photos are not scanned / `face_scan_status` stays `pending` + +1. Verify the `lychee_worker` container is running (`docker compose ps`). +2. Confirm `QUEUE_CONNECTION` is not `sync` in the Lychee worker environment. +3. Check the AI Vision service health endpoint. +4. Review `lychee_worker` logs: `docker compose logs lychee-worker`. + +### AI Vision service cannot find photos + +- Compare volume mounts: the host `./lychee/uploads` path must be the same in both the `lychee_api` and `ai_vision` volume definitions. +- Verify `VISION_FACE_PHOTOS_PATH` inside the container matches the volume mount destination. + +### API key mismatch errors (401 from AI Vision / Lychee) + +- `AI_VISION_FACE_API_KEY` (Lychee) must equal `VISION_FACE_API_KEY` (Python service). The same key is used in both directions. +- Restart both containers after changing the secret. + +### Selfie claim returns "no match found" + +- Lower `ai_vision_face_selfie_confidence_threshold` (default `0.8`) in admin settings to accept less-certain matches. +- Ensure the photo library has been fully scanned first. + +--- + +*Last updated: 2026-03-22* diff --git a/docs/specs/2-how-to/docker-compose/docker-compose-ai-vision-only.yaml b/docs/specs/2-how-to/docker-compose/docker-compose-ai-vision-only.yaml new file mode 100644 index 00000000000..0ff06f3bacd --- /dev/null +++ b/docs/specs/2-how-to/docker-compose/docker-compose-ai-vision-only.yaml @@ -0,0 +1,43 @@ +services: + ai_vision: + expose: + - "${APP_PORT_AI_FACE:-8001}" + ports: + - "${APP_PORT_AI_FACE:-8001}:8000" + # build: + # context: ./ai-vision-service + # container_name: lychee-ai-vision + image: lychee-ai-vision + restart: unless-stopped + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + environment: + VISION_FACE_LYCHEE_API_URL: "${AI_VISION_FACE_LYCHEE_API_URL:-http://lychee_api:8000}" + VISION_FACE_API_KEY: "${AI_VISION_API_KEY:-changeme}" + VISION_FACE_VERIFY_SSL: "${AI_VISION_VERIFY_SSL:-true}" + VISION_FACE_PHOTOS_PATH: "/data/photos" + VISION_FACE_STORAGE_PATH: "/data/embeddings" + VISION_FACE_WORKERS: "${AI_VISION_WORKERS:-1}" + volumes: + - ../../../../public/uploads:/data/photos:ro + - ai_vision_embeddings:/data/embeddings + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - local + + +volumes: + ai_vision_embeddings: + name: lychee_ai_vision_embeddings + driver: local + +networks: + local: + driver: bridge \ No newline at end of file diff --git a/docs/specs/3-reference/database-schema.md b/docs/specs/3-reference/database-schema.md index 5b24201c441..3306c4253a2 100644 --- a/docs/specs/3-reference/database-schema.md +++ b/docs/specs/3-reference/database-schema.md @@ -125,6 +125,51 @@ Color palette information extracted from photos. **Relationships:** - Belongs to `Photo` +#### Face +A detected face bounding box within a specific photo. + +**Key Fields:** +- `id`: Primary key (24-char random string) +- `photo_id`: Foreign key to Photo (cascade delete) +- `person_id`: Foreign key to Person (nullable; null on delete) +- `x`, `y`, `width`, `height`: Bounding box as relative floats (0.0–1.0) +- `confidence`: Detection confidence score (0.0–1.0) +- `crop_token`: Opaque token for serving cropped face thumbnails +- `is_dismissed`: Whether the face has been dismissed/ignored by an operator + +**Relationships:** +- Belongs to `Photo` +- Belongs to `Person` (nullable) +- Has many `FaceSuggestion` (as the source face) + +#### FaceSuggestion +Pairs of faces ranked by embedding similarity (used to suggest identities for unknown faces). + +**Key Fields:** +- `face_id`: Foreign key to Face (cascade delete) — the unidentified face +- `suggested_face_id`: Foreign key to Face (cascade delete) — an already-assigned face of a known Person +- `confidence`: Similarity score (0.0–1.0) + +**Unique Constraint:** Composite unique index on (`face_id`, `suggested_face_id`) + +**Relationships:** +- Both keys belong to `Face` + +#### Person +An identified individual who appears across one or more photos. + +**Key Fields:** +- `id`: Primary key (24-char random string) +- `name`: Display name (max 255 chars, required) +- `user_id`: Foreign key to User (nullable, unique — one Person per User claim; null on delete) +- `is_searchable`: When false, face overlays for this Person are hidden from non-owners (privacy) + +**Unique Constraint:** `user_id` is unique (one User → at most one claimed Person) + +**Relationships:** +- Optionally belongs to `User` (the claimed user) +- Has many `Face` + #### PhotoRating User ratings for photos on a 1-5 star scale. @@ -229,11 +274,13 @@ This dual approach allows Lychee to provide: ### Many-to-Many - **Photos-Albums**: Many-to-many through `photo_album` pivot table (photos can belong to multiple albums) - **Tags**: Many-to-many with photos through `photos_tags` pivot table +- **Face Suggestions**: Many-to-many between Face records through `face_suggestions` pivot table (ranked by similarity confidence) ### One-to-Many - **Photos**: Owned by one user (but can belong to multiple albums) - **Size Variants**: Multiple variants per photo - **Access Permissions**: Multiple permissions per album +- **Faces**: Multiple detected faces per photo; multiple faces per Person ## Database Optimization @@ -256,6 +303,34 @@ Eager loading enforced with `Model::shouldBeStrict()`, which throws an exception - [Tag System](../4-architecture/tag-system.md) - Tag architecture and operations - [Image Processing](image-processing.md) - Size variant generation and processing pipeline +## AI Vision Schema Additions (Feature 030) + +### New Tables + +| Table | Migration | Purpose | +|---|---|---| +| `persons` | `2026_03_21_000001_create_persons_table` | Identified individuals (name, user link, searchability) | +| `faces` | `2026_03_21_000002_create_faces_table` | Bounding boxes detected by AI Vision service | +| `face_suggestions` | `2026_03_21_000002_create_faces_table` | Ranked similarity pairs for identity suggestions | + +### Column Added to `photos` + +| Column | Type | Default | Purpose | +|---|---|---|---| +| `face_scan_status` | string(16), nullable | `null` | Tracks face detection lifecycle: `null` (not queued), `pending`, `scanned`, `failed` | + +### New Config Keys (AI Vision category, `level = 1` / SE only) + +| Key | Default | Description | +|---|---|---| +| `ai_vision_enabled` | `0` | Master toggle for the AI Vision subsystem | +| `ai_vision_face_enabled` | `0` | Enable facial recognition (requires master toggle) | +| `ai_vision_face_permission_mode` | `restricted` | Access control mode: `public`, `private`, `privacy-preserving`, `restricted` | +| `ai_vision_face_selfie_confidence_threshold` | `0.8` | Minimum confidence for selfie-based person claim | +| `ai_vision_face_person_is_searchable_default` | `1` | Default `is_searchable` for new Person records | +| `ai_vision_face_allow_user_claim` | `1` | Allow non-admin users to claim a Person profile | +| `ai_vision_face_scan_batch_size` | `200` | Photo IDs per bulk-scan job chunk | + --- -*Last updated: January 21, 2026* +*Last updated: March 22, 2026* diff --git a/docs/specs/4-architecture/features/030-ai-vision-service/plan.md b/docs/specs/4-architecture/features/030-ai-vision-service/plan.md index 7262ecbc1e2..fc1e21341c3 100644 --- a/docs/specs/4-architecture/features/030-ai-vision-service/plan.md +++ b/docs/specs/4-architecture/features/030-ai-vision-service/plan.md @@ -2,7 +2,7 @@ _Linked specification:_ `docs/specs/4-architecture/features/030-ai-vision-service/spec.md` _Status:_ Draft -_Last updated:_ 2026-03-18 +_Last updated:_ 2026-04-04 > Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](../../open-questions.md), and assume clarifications are resolved only when the spec's normative sections and, where applicable, ADRs have been updated. @@ -29,10 +29,20 @@ Enable Lychee users to browse their photo library by the people who appear in th - Feature and unit tests for all backend functionality. - **Python facial recognition service**: face detection, embedding generation, clustering, similarity matching, REST API with callback support. - **Docker image**: Dockerfile for the Python service, docker-compose integration, deployment documentation. + - **Face dismiss UX**: Dismiss button in modal + CTRL+click shortcut on overlays *(Q-030-54)*. + - **Maintenance blocks**: Destroy dismissed faces, reset stuck/failed scans with conditional visibility *(Q-030-55)*. + - **Batch face operations**: Multi-select faces, unassign/reassign/uncluster *(Q-030-56, Q-030-58)*. + - **Unassign face from person**: Return face to unassigned pool *(Q-030-57)*. + - **Person miniature in dropdowns**: Circular crop in assignment/merge dropdowns *(Q-030-59)*. + - **Face circles in detail panel**: Photo sidebar shows circular face crops with click/CTRL+click interactions *(Q-030-60)*. + - **Face overlay config**: Global enable/disable toggle + default visibility setting + P-key toggle *(Q-030-61)*. + - **Album people endpoint**: List persons found in an album *(Q-030-62)*. + - **Merge person UI**: Modal with person search and miniatures *(Q-030-58)*. - **Out of scope:** - Training custom face recognition models (use pre-trained models like InsightFace/dlib/face_recognition). - - Cluster review/confirmation UI (Lychee consumes cluster suggestions; dedicated review workflow is a follow-up). + - Per-user face overlay preferences (deferred — currently global config only). + - Policy refinement for album/photo edit rights cross-check (deferred — Q-030-63). ## Dependencies & Interfaces @@ -79,7 +89,7 @@ After each increment, verify: ## Increment Map -> **Implementation order: Python service first (I1–I3), then PHP/Lychee backend (I4–I12), then frontend (I13–I18), then docs (I19).** +> **Implementation order: Python service first (I1–I3), then PHP/Lychee backend (I4–I12), then frontend (I13–I18), then docs (I19), then clustering & embedding sync (I20–I22), then face UX enhancements (I23–I30).** ### Phase 1: Python Facial Recognition Service @@ -251,7 +261,47 @@ After each increment, verify: - _Commands:_ `php artisan test --filter=PersonPhotos`, `make phpstan` - _Exit:_ Paginated photos returned; access control respected. -### Phase 3: Frontend (Vue3/TypeScript) +### I20 – Clustering Endpoint: Python `POST /cluster` + PHP Ingestion & Trigger (≥90 min) + +- _Goal:_ Wire up the existing `FaceClusterer` (DBSCAN) to a FastAPI REST endpoint and complete the round-trip: Python runs clustering, posts suggestion pairs to Lychee, Lychee bulk-upserts `face_suggestions`. Admin can trigger clustering via a Maintenance API action. +- _Preconditions:_ I2 complete (`FaceClusterer` and `EmbeddingStore` implemented); I10 complete (`face_suggestions` table exists, PHP ingestion pipeline in place); I11 complete (Maintenance endpoint pattern established). +- _Steps:_ + 1. **Python** — Add `ClusterResponse` Pydantic schema (`{clusters: int, suggestions_generated: int}`) to `app/api/schemas.py`. Add `VISION_FACE_CLUSTER_EPS` env var to `AppSettings` (default `0.6`). + 2. **Python** — Extend `app/clustering/clusterer.py` with `run_cluster_and_notify(store: EmbeddingStore, lychee_url: str, api_key: str) -> ClusterResponse`: reads all embeddings from store, runs DBSCAN, produces (a) a `labels` list — `[{face_id: str, cluster_label: int}]` for every non-noise face; (b) a `suggestions` list — `(face_id, suggested_face_id, confidence)` pairs for every intra-cluster pair (cosine similarity as confidence). POSTs `{labels: [...], suggestions: [...]}` to `{lychee_url}/api/v2/FaceDetection/cluster-results` with `X-API-Key` header. *(Q-030-49)* + 3. **Python** — Add `POST /cluster` route to `app/api/routes.py` (X-API-Key auth); calls `run_cluster_and_notify()`; returns `ClusterResponse`. Add unit + integration tests in `tests/test_clustering.py` (mock httpx POST to Lychee). + 4. **PHP** — Implement `POST /api/v2/FaceDetection/cluster-results` endpoint in `FaceDetectionController` (or a new `FaceClusterResultsController`): auth via X-API-Key, validate body `{suggestions: [{face_id, suggested_face_id, confidence}]}`, bulk-upsert `face_suggestions` rows (upsert on `(face_id, suggested_face_id)`, update `confidence`), return `{updated_count: N}`. Register route. + 5. **PHP** — Add `POST /api/v2/Maintenance::runFaceClustering` Maintenance endpoint (admin-only, follows existing check/do pattern): calls Python service `POST /cluster` via HTTP, returns 202 Accepted. Register route. + 6. Write PHP feature tests for cluster-results ingestion (success, invalid API key, malformed body) and for the Maintenance trigger (success, service unavailable 503). +- _Commands:_ `uv run pytest tests/test_clustering.py`, `php artisan test --filter=FaceCluster`, `make phpstan` +- _Exit:_ `POST /cluster` on Python service triggers DBSCAN and POSTs pairs to Lychee; `POST /FaceDetection/cluster-results` bulk-upserts face_suggestions; Maintenance trigger returns 202; all tests green. + +### I21 – Embedding Sync on Deletion + Blur Threshold Filtering (≈60 min) + +- _Goal:_ Prevent stale embeddings from corrupting clustering/suggestions after face hard-deletes; discard blurry faces before they ever reach Lychee. +- _Preconditions:_ I2 complete (EmbeddingStore implemented); I9 complete (`destroyDismissed` action exists); Photo model cascade delete exists. +- _Steps:_ + 1. **Python** — Add `VISION_FACE_BLUR_THRESHOLD` (float, default `100.0`) to `AppSettings` in `app/config.py`. In `app/detection/detector.py`, after cropping each detected face region, compute its Laplacian variance using OpenCV/NumPy (`cv2.Laplacian(crop, cv2.CV_64F).var()`); exclude faces whose variance is below `VISION_FACE_BLUR_THRESHOLD`. Also add `VISION_FACE_CLUSTER_EPS` to `AppSettings` (default `0.6`) if not already present from I20. + 2. **Python** — Add `DELETE /embeddings` route to `app/api/routes.py` (X-API-Key auth): accepts `{face_ids: [str]}`, calls `EmbeddingStore.delete_many(face_ids)`, returns `{deleted_count: int}`. Add `delete_many()` method to the `EmbeddingStore` protocol and both implementations (`SQLiteStore`, `PgVectorStore`). IDs not found are silently ignored. + 3. **Python** — Add tests: `tests/test_detection.py` — blurry face below threshold not returned; sharp face above threshold returned. `tests/test_api.py` — `DELETE /embeddings` removes embeddings and returns count. + 4. **PHP** — Create `DeleteFaceEmbeddingsJob` (queued): accepts `array $faceIds`, calls Python `DELETE /embeddings` via HTTP with `X-API-Key`; logs warning on failure, never throws. Dispatch this job **after** `destroyDismissed` deletes Face records (FR-030-14, S-030-28). + 5. **PHP** — Add `Face` model observer or hook into `Photo` cascade: after a Photo delete triggers Face cascade deletes, collect deleted face IDs and dispatch `DeleteFaceEmbeddingsJob` (S-030-29). + 6. **PHP** — Write feature tests: `DELETE /Face/dismissed` → embeddings deleted (job dispatched with correct IDs); Photo delete → embeddings deleted; Python unavailable → Lychee deletion still succeeds, warning logged. +- _Commands:_ `uv run pytest tests/test_detection.py tests/test_api.py`, `php artisan test --filter=FaceEmbeddingSync`, `make phpstan` +- _Exit:_ Blurry faces never reach Lychee; dismissed/cascade-deleted Face embeddings are removed from the store; service unavailability does not block Lychee-side deletions. + +### I22 – Cluster Review UI: Browse & Bulk-Name/Dismiss Clusters (≥90 min) + +- _Goal:_ Give authorized users a dedicated page to review DBSCAN-produced face clusters (visually similar unassigned faces) and resolve them in bulk — either creating a Person and assigning all faces in one action, or dismissing the whole cluster as false-positives. +- _Preconditions:_ I20 complete (`face_suggestions` populated by clustering); I9 complete (dismiss action exists); I7 complete (Person create + face assign available). +- _Steps:_ + 1. **PHP** — Add migration for `cluster_label INT NULL` column + composite index `(cluster_label, person_id, is_dismissed)` on `faces` (DO-030-07, Q-030-49). Implement `GET /api/v2/FaceDetection/clusters` (API-030-18): `SELECT cluster_label, COUNT(*) as size FROM faces WHERE cluster_label IS NOT NULL AND person_id IS NULL AND is_dismissed = false GROUP BY cluster_label ORDER BY cluster_label LIMIT/OFFSET`; load preview faces per cluster; return `{cluster_id: int, size: int, faces: FaceResource[]}`. `cluster_id` = integer `cluster_label` value. Respect `ai_vision_face_permission_mode` visibility rules. + 2. **PHP** — Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/assign` (API-030-19): resolve cluster faces, create Person if `new_person_name` provided, bulk-update `face.person_id` for all faces in cluster, emit `face.cluster_assigned` telemetry. Return `{person_id, assigned_count}`. + 3. **PHP** — Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss` (API-030-20): bulk-set `is_dismissed = true` for all faces in cluster, emit `face.cluster_dismissed` telemetry. Return `{dismissed_count}`. + 4. Write feature tests: list clusters (unassigned faces grouped, assigned faces excluded), assign cluster (new person + faces linked), dismiss cluster (all faces dismissed). Test permission mode enforcement. + 5. **Frontend** — Create `FaceClusters.vue` page at `/people/clusters`. Paginated grid of cluster cards: first 5 face-crop thumbnails + "+N more" overflow badge, cluster size badge, name input, “Create Person & Assign All” button, “Dismiss” button. “Run Cluster” button in page header calls `POST /Maintenance::runFaceClustering` then refreshes. Empty state when no clusters exist. + 6. Add route `/people/clusters` to Vue Router; add “Clusters” navigation link under People in sidebar. +- _Commands:_ `php artisan test --filter=FaceClusterReviewTest`, `make phpstan`, `npm run check` +- _Exit:_ Clusters page shows unassigned face groups; bulk-assign creates Person and links all faces; bulk-dismiss marks all is_dismissed; “Run Cluster” triggers fresh clustering; all tests green. ### I13 – Frontend: People Page (≈90 min) @@ -351,6 +401,105 @@ After each increment, verify: - _Commands:_ Full quality gate commands. - _Exit:_ All gates green; documentation current. +### Phase 5: Face UX Enhancements (Q-030-54 through Q-030-64) + +### I23 – Face Dismiss UX: Modal Button + CTRL+Click Overlay (≈60 min) + +- _Goal:_ Add dismiss functionality to the FaceAssignmentModal and CTRL+click shortcut on face overlays (desktop only). +- _Preconditions:_ I15 (FaceOverlay.vue) and I16 (FaceAssignmentModal.vue) complete. +- _Steps:_ + 1. **Frontend** — Add "Dismiss" button to FaceAssignmentModal.vue. Clicking calls `PATCH /Face/{id}` to set `is_dismissed = true`, then closes the modal and refreshes overlays. *(FR-030-16)* + 2. **Frontend** — In FaceOverlay.vue, check `isTouchDevice()` from `keybindings-utils.ts`. On non-touch devices only, listen for CTRL `keydown`/`keyup` events on `window`. When CTRL is held, switch all face rectangle CSS to red dashed borders. When a rectangle is clicked in CTRL state, call `PATCH /Face/{id}` directly (no modal). After dismiss, remove the overlay element. Touch devices: no CTRL+click — modal button only. *(Q-030-70: B)* + 3. Write JS unit test: CTRL state toggles overlay CSS classes; click in CTRL state fires dismiss API call. +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Dismiss button works in modal; CTRL+click dismiss works on desktop overlays; touch devices unaffected; visual feedback correct; tests green. + +### I24 – Face Overlay Config Settings & P-Key Toggle (≈45 min) + +- _Goal:_ Add config settings for face overlay enable/disable and default visibility; map P key to toggle. +- _Preconditions:_ I4 (config migration pattern established), I15 (FaceOverlay.vue exists). +- _Steps:_ + 1. **PHP** — Add config migration for `ai_vision_face_overlay_enabled` (0|1, default 1) and `ai_vision_face_overlay_default_visibility` (string: `visible`|`hidden`, default `visible`) to the AI Vision category. *(NFR-030-11)* + 2. **Frontend** — In FaceOverlay.vue, gate overlay rendering on `ai_vision_face_overlay_enabled` config. Initialize overlay visibility from `ai_vision_face_overlay_default_visibility`. + 3. **Frontend** — Register `P` key handler (on photo view) to toggle overlay visibility. `P` is confirmed unbound — `F` maps to fullscreen. Use `onKeyStroke('p', ...)` pattern from `@vueuse/core` with `shouldIgnoreKeystroke()` guard. *(Q-030-65: A)* + 4. Write PHP migration test; verify config values accessible. +- _Commands:_ `php artisan test`, `npm run check`, `npm run format` +- _Exit:_ Overlay disabled when config is off; default visibility respected; P key toggles; no key binding conflicts. + +### I25 – Face Circles in Photo Detail Panel (≈60 min) + +- _Goal:_ Display circular face crop thumbnails in the photo details sidebar with click/CTRL+click interactions. +- _Preconditions:_ I15 (face overlays), I16 (FaceAssignmentModal), I23 (CTRL+click dismiss). +- _Steps:_ + 1. **Frontend** — Add "People in this photo" section to `PhotoDetails.vue`. Render a horizontal flex row (`overflow-x: auto`) of circular face crop images (48px, `border-radius: 50%`) with person name label below each. Unassigned faces show "???". Hidden when photo has no faces or when `ai_vision_face_overlay_enabled = 0`. + 2. **Frontend** — Click on a face circle → open FaceAssignmentModal for that face. + 3. **Frontend** — CTRL+click on a face circle (desktop only) → dismiss face directly (same pattern as I23). *(Q-030-70: B — no touch shortcut)* + 4. Overflow handled by horizontal scroll (`overflow-x: auto`) — all circles accessible by scrolling. *(Q-030-71: A)* +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Face circles render in detail panel; click/CTRL+click interactions work; overflow handled; tests green. + +### I26 – Batch Face Operations: API + Frontend (≈90 min) + +- _Goal:_ Implement batch face selection, unassign, reassign, uncluster operations in both backend and frontend. +- _Preconditions:_ I9 (face assignment), I22 (cluster review), I14 (person detail). +- _Steps:_ + 1. **PHP** — Implement `POST /api/v2/Face/batch` endpoint in FaceController. Body: `{face_ids: [str], action: "unassign"|"assign", person_id?: str, new_person_name?: str}`. Validation: face_ids non-empty, action valid, person_id or new_person_name for assign. Auth: check assign permission for each face. Returns `{affected_count, person_id?}`. *(FR-030-19, API-030-24)* + 2. **PHP** — Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster` endpoint. Body: `{face_ids: [str]}`. Sets `cluster_label = NULL` for qualifying faces. Returns `{unclustered_count}`. *(FR-030-17, API-030-23)* + 3. **PHP** — Write feature tests: batch unassign, batch assign to existing person, batch assign to new person, uncluster faces, auth checks. + 4. **Frontend** — Add "Select Mode" toggle button to PersonDetail.vue and FaceClusters.vue. When active, checkbox overlays appear on each face crop. + 5. **Frontend** — Add action bar (slides in at bottom when faces selected): "Unassign (N)", "Reassign to...", "Assign to new person", "Uncluster" (cluster view only). Each calls the corresponding API. + 6. Create `FaceBatchService.ts` with typed functions. +- _Commands:_ `php artisan test --filter=FaceBatch`, `make phpstan`, `npm run check` +- _Exit:_ Batch operations work end-to-end; select mode activates cleanly; action bar renders; all tests green. + +### I27 – Maintenance Blocks: Dismiss Cleanup + Combined Reset Stuck/Failed Scans (≈60 min) + +- _Goal:_ Add two conditional maintenance blocks for face cleanup and scan reset operations. *(Q-030-73: combined stuck+failed into one block)* +- _Preconditions:_ I9 (dismiss exists), I11 (stuck reset exists), maintenance pattern established. +- _Steps:_ + 1. **PHP** — Implement `DestroyDismissedFaces` maintenance controller with check/do pattern. `check()` returns count of `Face::where('is_dismissed', true)->count()`. `do()` reuses `destroyDismissed` logic from FaceController. *(FR-030-23, API-030-21/21b)* + 2. **PHP** — Implement `ResetFaceScanStatus` maintenance controller (combined stuck + failed). `check()` returns sum of: stuck-pending (>720 min) + `face_scan_status = 'failed'`. `do()` resets both in one DB operation. Returns `{reset_count: N}`. *(FR-030-24, API-030-22/22b, Q-030-73)* + 3. **PHP** — Register maintenance routes in `api_v2.php`. Write feature tests for both check/do endpoints. + 4. **Frontend** — Create `MaintenanceDestroyDismissedFaces.vue`: calls check endpoint on mount, hides when count is 0, "Destroy All" button calls POST endpoint. Follow existing `MaintenanceBulkScanFaces.vue` pattern. + 5. **Frontend** — Create `MaintenanceResetFaceScanStatus.vue` (NOT separate components for stuck and failed — combined per Q-030-73). Same pattern: check on mount, hide when 0, "Reset All" button. + 6. **Frontend** — Add both maintenance cards to `Maintenance.vue` template. +- _Commands:_ `php artisan test --filter=Maintenance`, `make phpstan`, `npm run check` +- _Exit:_ Both maintenance blocks appear conditionally; check returns correct counts; do performs cleanup; cards hidden when count is 0. + +### I28 – Merge Person UI + Person Miniature in Dropdown (≈60 min) + +- _Goal:_ Implement merge person modal and add person miniatures to the face assignment dropdown. +- _Preconditions:_ I8 (merge backend), I16 (FaceAssignmentModal), I14 (PersonDetail). +- _Steps:_ + 1. **Frontend** — Create `MergePersonModal.vue`. Triggered by "Merge into..." button on PersonDetail.vue. Shows source person info, PrimeVue Dropdown with person search (custom option template: 24px circular miniature + name + face count), warning text about merge consequences, Cancel/Merge buttons. On confirm, calls `POST /Person/{id}/merge`. *(FR-030-25)* + 2. **Frontend** — Update FaceAssignmentModal.vue dropdown to use custom `option` template with circular miniature (from `representative_crop_url`), person name, and face count. Use PrimeVue Dropdown's `optionLabel` slot. Fallback placeholder icon when no crop exists. *(FR-030-20)* + 3. After merge, redirect from source person page to target person page. +- _Commands:_ `npm run check`, `npm run format` +- _Exit:_ Merge modal opens from PersonDetail; person search works with miniatures; merge executes and redirects; assignment dropdown shows miniatures. + +### I29 – Album People Endpoint (≈45 min) + +- _Goal:_ New endpoint returning persons found in a given album. +- _Preconditions:_ I7 (People CRUD), photo_albums relationship exists. +- _Steps:_ + 1. **PHP** — Implement `AlbumPeopleController` with `index()` action. `GET /api/v2/Album/{id}/people`: query `SELECT DISTINCT persons.* FROM persons JOIN faces ... JOIN photo_albums WHERE photo_albums.album_id = ?` (non-recursive, direct photos only). Return `PaginatedPersonsResource`. Respect `ai_vision_face_permission_mode` and `is_searchable` filtering. *(FR-030-22, API-030-25)* + 2. **PHP** — Create `AlbumPeopleRequest` form request; validate album_id exists, user has album access. + 3. **PHP** — Write feature tests: album with persons returns correct list; empty album returns empty; non-searchable filtering; access control. + 4. **PHP** — Register route in `api_v2.php`. +- _Commands:_ `php artisan test --filter=AlbumPeople`, `make phpstan` +- _Exit:_ Album people endpoint returns correct persons; pagination works; access control verified. + +### I30 – Unassign Face from Person (≈30 min) + +- _Goal:_ Allow unassigning a face from a person, returning it to the unassigned pool. +- _Preconditions:_ I9 (face assignment API). +- _Steps:_ + 1. **PHP** — Update `POST /Face/{id}/assign` to accept `person_id: null` (or empty string) as a valid value that unassigns the face (sets `face.person_id = NULL`). Emit `face.unassigned` telemetry. *(FR-030-18, API-030-25)* + 2. **PHP** — Write feature test: assign face, then unassign (person_id = null); verify face returns to unassigned state. + 3. **Frontend** — In PersonDetail.vue (non-batch mode), add "Remove" button or context menu option on each face crop that calls assign with `person_id: null`. +- _Commands:_ `php artisan test --filter=FaceAssignment`, `make phpstan`, `npm run check` +- _Exit:_ Face unassign works via API; PersonDetail UI allows removal; face returns to unassigned pool. + ## Scenario Tracking | Scenario ID | Increment / Task reference | Notes | @@ -381,6 +530,26 @@ After each increment, verify: | S-030-24 | I9 | Face dismissed (is_dismissed toggle) | | S-030-25 | I9 | Admin hard-deletes all dismissed faces + crop files | | S-030-26 | I11 | Admin resets stuck-pending photos via Maintenance endpoint | +| S-030-27 | I20 | Admin triggers clustering; suggestion pairs ingested into face_suggestions | +| S-030-28 | I21 | Admin hard-deletes dismissed faces; embeddings deleted from Python store | +| S-030-29 | I21 | Photo deleted; Face cascade-deleted; embeddings deleted from Python store | +| S-030-30 | I21 | Blurry face below VISION_FACE_BLUR_THRESHOLD excluded from detection callback | +| S-030-31 | I22 | Cluster Review page: admin names cluster → Person created, all faces assigned | +| S-030-32 | I22 | Cluster Review page: admin dismisses cluster → all faces marked is_dismissed | +| S-030-33 | I23 | Dismiss button in FaceAssignmentModal | +| S-030-34 | I23 | CTRL+click dismiss on face overlays (red dashed borders) | +| S-030-35 | I26 | Uncluster selected faces from a cluster | +| S-030-36 | I30 | Unassign face from person (person_id = NULL) | +| S-030-37 | I26 | Batch select + reassign faces in person detail | +| S-030-38 | I25 | Face circles in photo detail panel (click → modal) | +| S-030-39 | I25 | CTRL+click face circle in detail panel → dismiss | +| S-030-40 | I29 | Album people endpoint returns persons in album | +| S-030-41 | I24 | P key toggles face overlay visibility | +| S-030-42 | I27 | Maintenance destroy dismissed faces block | +| S-030-43 | I27 | Maintenance reset failed face scans block | +| S-030-44 | I24 | Face overlay disabled when config is off | +| S-030-45 | I28 | Person merge from UI with merge modal | +| S-030-46 | I28 | Person miniature in face assignment dropdown | ## Analysis Gate @@ -388,7 +557,7 @@ _To be completed after spec, plan, and tasks align and before implementation beg ## Exit Criteria -- [ ] All 19 increments (I1–I19) complete with passing tests. +- [ ] All 30 increments (I1–I30) complete with passing tests. - [ ] PHPStan 0 errors. - [ ] php-cs-fixer clean. - [ ] npm run check / npm run format clean. @@ -401,9 +570,9 @@ _To be completed after spec, plan, and tasks align and before implementation beg ## Follow-ups / Backlog -- Auto-clustering UI — Python service clusters via `POST /cluster` (DBSCAN offline batch); dedicated cluster-review/confirm workflow is a future enhancement beyond manual assignment. *(Q-030-30 resolved)* - Face recognition accuracy tuning and confidence threshold configuration (admin UI for `VISION_FACE_DETECTION_THRESHOLD` / `VISION_FACE_MATCH_THRESHOLD`). - Notifications when a user is tagged in a new photo. - Performance optimisation for large Person/Face datasets (materialized views, caching face counts). - GPU acceleration for the Python service (optional CUDA/ROCm support in Dockerfile). -- Cluster review UI — surface DBSCAN group results for bulk confirmation by admin. +- Per-user face overlay visibility preference (currently global config only — Q-030-61). +- Policy refinement: cross-check album/photo edit rights in AiVisionPolicy (Q-030-63, Q-030-72 — deferred to future iteration). diff --git a/docs/specs/4-architecture/features/030-ai-vision-service/spec.md b/docs/specs/4-architecture/features/030-ai-vision-service/spec.md index 02b8522c9fb..cfcb36c385e 100644 --- a/docs/specs/4-architecture/features/030-ai-vision-service/spec.md +++ b/docs/specs/4-architecture/features/030-ai-vision-service/spec.md @@ -3,7 +3,7 @@ | Field | Value | |-------|-------| | Status | Draft | -| Last updated | 2026-03-18 | +| Last updated | 2026-04-04 | | Owners | LycheeOrg | | Linked plan | `docs/specs/4-architecture/features/030-ai-vision-service/plan.md` | | Linked tasks | `docs/specs/4-architecture/features/030-ai-vision-service/tasks.md` | @@ -42,7 +42,7 @@ Affected modules: **Models** (new Person, Face), **Migrations** (new tables + pi | ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | |----|-------------|--------------|-----------------|--------------|---------------------|--------| | FR-030-01 | System shall store Person records with a name, optional User link (1-1), and a searchability flag. | Person created with name; optionally linked to a User; `is_searchable` defaults to the value of `ai_vision_face_person_is_searchable_default` config (default `true`). | Name must be non-empty string ≤255 chars; user_id must reference existing User and be unique across Person table (1-1). | Return 422 with validation errors. | `person.created`, `person.updated` | Owner directive | -| FR-030-02 | System shall store Face records linking a detected face region in a Photo to an optional Person, including a server-side crop thumbnail path and a dismissal flag. *(Resolved Q-030-09: server-side crop; Q-030-16: is_dismissed; Q-030-25: crop storage path; Q-030-34: nginx-direct hash path)* | Face created with photo_id, bounding box (x, y, width, height as percentages), confidence score, `crop_token` (random high-entropy token, stored on Face model); crop file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx (path is unguessable, no app-level auth required); person_id nullable (unassigned); `is_dismissed` defaults to `false`. | photo_id must exist; bounding box values 0.0–1.0; confidence 0.0–1.0. | Return 422; reject invalid photo_id with 404. | `face.created` | Owner directive, Q-030-09, Q-030-16, Q-030-25 | +| FR-030-02 | System shall store Face records linking a detected face region in a Photo to an optional Person, including a server-side crop thumbnail path and a dismissal flag. The Python service shall filter detections by both confidence and sharpness before sending results to Lychee. *(Resolved Q-030-09: server-side crop; Q-030-16: is_dismissed; Q-030-25: crop storage path; Q-030-34: nginx-direct hash path)* | Face created with photo_id, bounding box (x, y, width, height as percentages), confidence score, `crop_token` (random high-entropy token, stored on Face model); crop file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx (path is unguessable, no app-level auth required); person_id nullable (unassigned); `is_dismissed` defaults to `false`. Python service filters detections by two thresholds before callback: (a) `VISION_FACE_DETECTION_THRESHOLD` (confidence; default 0.5) — faces below excluded; (b) `VISION_FACE_BLUR_THRESHOLD` (Laplacian variance sharpness score; default `100.0`) — faces whose crop has a Laplacian variance below this value are excluded as too blurry to be usable for recognition. | photo_id must exist; bounding box values 0.0–1.0; confidence 0.0–1.0. | Return 422; reject invalid photo_id with 404. | `face.created` | Owner directive, Q-030-09, Q-030-16, Q-030-25 | | FR-030-03 | A Person can appear in multiple Photos (many-to-many through Face). The system shall provide an endpoint to list all photos containing a given Person. | GET endpoint returns paginated photos where at least one Face with matching person_id exists. | person_id must exist; pagination params validated. | 404 if Person not found; empty result set if no faces assigned. | — | Owner directive | | FR-030-04 | A Photo can contain multiple Persons. The system shall return all identified Persons when viewing a Photo's details. For non-searchable Persons, face overlays are hidden entirely for unauthorized viewers; a `hidden_face_count` integer is included instead. *(Resolved Q-030-10: hide overlay + count indicator)* | Photo detail response includes `faces` array (only searchable/authorized faces) + `hidden_face_count` (integer, count of suppressed non-searchable faces). | — | Graceful empty array if no faces detected; hidden_face_count = 0 if none suppressed. | — | Owner directive, Q-030-10 | | FR-030-05 | Users can link their account to a Person (1-1) via direct claim, and unlink via unclaim. Admins can link/unlink any Person-User pair, overriding user claims. Only one Person per User, one User per Person. *(Resolved Q-030-06: self-identification + admin override; Q-030-21: unclaim endpoint)* | User claims a Person; `person.user_id` set; old claim (if any) cleared. Admin can force-link/unlink any pair. User unclaims via `DELETE /api/v2/Person/{id}/claim`; sets `person.user_id = null`. | user_id unique on persons table; User must exist. Non-admin claim: 409 if Person already claimed by another User; 403 if `ai_vision_face_allow_user_claim` is `false`. Admin claim: overrides existing link; bypasses `ai_vision_face_allow_user_claim`. Unclaim: only linked User or admin. | 409 if Person already claimed (non-admin); 403 if `ai_vision_face_allow_user_claim` is `false` (non-admin); 403 if unclaim caller is not linked User or admin; 422 for validation errors. | `person.claimed`, `person.unclaimed` | Owner directive, Q-030-06, Q-030-21 | @@ -53,6 +53,19 @@ Affected modules: **Models** (new Person, Face), **Migrations** (new tables + pi | FR-030-10 | Users can manually assign/reassign an unassigned Face to a Person, or create a new Person from a Face. Python service provides cluster suggestions for grouping similar faces. *(Resolved Q-030-03: auto-cluster with manual confirmation)* | Face's person_id updated; new Person created if requested. Cluster suggestions displayed as similarity scores in assignment UI. | Face must exist; target Person (if specified) must exist. | 404/422 for invalid references. | `face.assigned` | Owner directive, Q-030-03 | | FR-030-11 | Users can merge two Person records (combining all their Face associations). *(Resolved Q-030-22: merge direction — URL {id} = target kept; body source_person_id = source destroyed)* | All Faces of source Person reassigned to target Person (`{id}`); source Person deleted. | Both Persons must exist; user must have edit permission per `ai_vision_face_permission_mode`; body must supply `source_person_id`. | 404 if either Person not found; 403 if unauthorized. | `person.merged` | Owner directive, Q-030-22 | | FR-030-12 | Users can upload a selfie photo to claim a Person via face matching. Selfie sent to Python service's dedicated `POST /match` endpoint; image discarded immediately after matching. *(Resolved Q-030-06: selfie-upload claim; Q-030-11: discard after match; Q-030-12: dedicated /match endpoint; Q-030-13: lychee_face_id returned by /match)* | User uploads selfie → Python service `POST /match` returns top-N matches with `lychee_face_id` + confidence → if best match above `ai_vision_face_selfie_confidence_threshold`, Lychee resolves `lychee_face_id → Face → person_id` and links Person to User (same 1-1 rules as FR-030-05). Selfie image deleted after response. | Selfie must contain exactly one detectable face; confidence threshold configurable. | 422 if no face detected in selfie; 404 if no matching Person found; 409 if matched Person already claimed by another User. | `person.selfie_claim_requested`, `person.selfie_claim_matched` | Owner directive, Q-030-06, Q-030-11, Q-030-12, Q-030-13 | +| FR-030-14 | When Face records are hard-deleted from Lychee (either via admin bulk-dismissed-delete or via cascade when a Photo is deleted), the corresponding embeddings shall also be removed from the Python service's embedding store to prevent stale data from polluting future clustering and suggestion results. | After hard-deleting Face records, Lychee calls Python `DELETE /embeddings` with `{face_ids: [str]}`. Python removes those embeddings from `EmbeddingStore`. The call is best-effort (fire-and-forget via queued job): Lychee proceeds with the deletion regardless of whether the Python service responds. Failures are logged as warnings. Dispatch is triggered at **two explicit call-sites** (no Face model observer): (1) `destroyDismissed` action — collect dismissed face IDs before `Face::where('is_dismissed', true)->delete()`, then dispatch `DeleteFaceEmbeddingsJob`; (2) `PhotoObserver::deleting` — collect `$photo->faces()->pluck('id')` before cascade delete, then dispatch batch job. *(Q-030-52)* | face_ids must be a non-empty list of strings. | If the Python service is unavailable, log warning and continue — do not fail or roll back the Lychee-side deletion. | `face.embeddings_deleted` (with `count`) | Owner directive, Q-030-52 | +| FR-030-15 | System shall provide a Cluster Review page where authorized users can browse face clusters produced by DBSCAN (faces grouped by visual similarity, not yet assigned to a Person) and bulk-name or dismiss an entire cluster in one action. | `GET /api/v2/FaceDetection/clusters` returns a paginated list of clusters; each cluster contains a `cluster_id` (integer, equal to `faces.cluster_label`, stable between clustering runs), a list of `FaceResource` items (crop_url, confidence, photo_id), and a `size` count. The page renders face-crop grids grouped by cluster. User can: (a) type a name and click “Create Person & assign all” — creates a new Person and bulk-assigns every face in the cluster to it via `POST /api/v2/FaceDetection/clusters/{cluster_id}/assign`; (b) click “Dismiss cluster” — marks every face `is_dismissed = true` via `POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss`. | Cluster review page respects `ai_vision_face_permission_mode` (same visibility rules as People page). `cluster_id` must be a valid integer `cluster_label` value with at least one qualifying face. | 404 if `cluster_id` not found; 403 if unauthorized. | `face.cluster_assigned` (with `cluster_id`, `person_id`, `face_count`), `face.cluster_dismissed` (with `cluster_id`, `face_count`) | Owner directive, Q-030-49 | +| FR-030-13 | Admin can trigger offline DBSCAN face clustering across all stored embeddings to generate cross-photo face suggestion pairs and persist cluster labels on Face records. The Python service exposes `POST /cluster`; Lychee exposes `POST /FaceDetection/cluster-results` to ingest the results. *(Q-030-49)* | Admin calls `POST /api/v2/Maintenance::runFaceClustering` (admin-only) → Lychee calls Python service `POST /cluster` (X-API-Key auth) → Python reads all stored embeddings from `EmbeddingStore`, runs DBSCAN (eps from `VISION_FACE_CLUSTER_EPS` env var, default configurable), generates: (a) `{face_id: str, cluster_label: int}[]` where label = DBSCAN integer label (noise faces omitted); (b) `(face_id, suggested_face_id, confidence)` pairs for every intra-cluster pair using cosine similarity → POSTs both to `POST /api/v2/FaceDetection/cluster-results` (X-API-Key auth) → Lychee: bulk-updates `faces.cluster_label` (all previously clustered faces first reset to NULL, then new labels written); bulk-upserts `face_suggestions` rows (unique on `(face_id, suggested_face_id)`); returns `{faces_labeled: N, suggestions_updated: M}`. Maintenance trigger returns 202 Accepted; clustering runs asynchronously on the Python side. | Python endpoint: X-API-Key required. PHP cluster-results endpoint: X-API-Key required; body: `{labels: [{face_id: str, cluster_label: int}], suggestions: [{face_id: str, suggested_face_id: str, confidence: float}]}`. Both arrays optional (empty array = no-op for that field). Maintenance trigger: admin-only session auth. | 503 if Python service unavailable; 401 for invalid API key; no-op if no embeddings stored (returns `{faces_labeled: 0, suggestions_updated: 0}`). | `face.clustering_triggered`, `face.cluster_labels_written` (with `faces_labeled`), `face.cluster_suggestions_ingested` (with `suggestions_updated`) | Owner directive, Q-030-49 | +| FR-030-16 | System shall support dismissing a face via: (a) a "Dismiss" button in the FaceAssignmentModal; (b) a CTRL+click shortcut on face overlays (desktop only) — when the CTRL key is held, overlay rectangles switch to red dashed borders, and clicking a rectangle directly dismisses the face. On touch devices, CTRL+click is not available; dismiss is only accessible via the modal button. *(Q-030-54, Q-030-70)* | (a) FaceAssignmentModal includes a "Dismiss" button that calls `PATCH /Face/{id}` to set `is_dismissed = true`. (b) FaceOverlay.vue monitors `keydown`/`keyup` for CTRL key on non-touch devices (checked via `isTouchDevice()` from `keybindings-utils.ts`); when CTRL is held, all face rectangles render with red dashed CSS borders; clicking a rectangle in this state calls `PATCH /Face/{id}` directly without opening the modal. After dismiss, the overlay is removed from view. | Modal dismiss: face must exist, user must have dismiss permission per `ai_vision_face_permission_mode`. CTRL+click: same authorization as modal dismiss; only available on non-touch devices. | 403 if unauthorized; 404 if face not found. | `face.dismissed` | Owner directive, Q-030-54, Q-030-70 | +| FR-030-17 | In the Cluster Review UI, users can select individual faces within a cluster and "uncluster" them — setting `cluster_label = NULL` on selected faces, removing them from the cluster without dismissing them. Supports batch selection. *(Q-030-56)* | New API: `POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster` with body `{face_ids: [str]}`. Sets `faces.cluster_label = NULL` for the specified face IDs (only if they belong to the given cluster_id and are qualifying — `person_id IS NULL AND is_dismissed = false`). Returns `{unclustered_count: int}`. | face_ids must be non-empty; all face_ids must belong to the specified cluster_id. | 404 if cluster_id not found; 422 if face_ids empty or invalid; 403 if unauthorized. | `face.unclustered` (with `cluster_id`, `face_count`) | Owner directive, Q-030-56 | +| FR-030-18 | Users can remove a face from a person (unassign), making it unassigned again (not dismissed). Distinct from dismissal — unassign returns the face to the unassigned pool. *(Q-030-57)* | `POST /Face/{id}/assign` accepts `person_id: null` to unassign the face. Sets `face.person_id = NULL`. The face becomes available for future cluster runs and manual assignment. | Face must exist; user must have assign permission. | 404 if face not found; 403 if unauthorized. | `face.unassigned` (with `face_id`, `previous_person_id`) | Owner directive, Q-030-57 | +| FR-030-19 | In person detail and cluster review views, users can select multiple faces (batch selection mode), then choose: (a) unassign all selected (`person_id = NULL`), (b) assign all to an existing person, (c) assign all to a new person. *(Q-030-58)* | New API: `POST /api/v2/Face/batch` with body `{face_ids: [str], action: "unassign"\|"assign", person_id?: str, new_person_name?: str}`. For "unassign": sets `person_id = NULL` on all specified faces. For "assign": sets `person_id` to the given person (or creates a new Person from `new_person_name`). Returns `{affected_count: int, person_id?: str}`. | face_ids must be non-empty; action must be "unassign" or "assign"; for "assign", either `person_id` or `new_person_name` required. | 422 if validation fails; 403 if unauthorized for any face; 404 if person_id not found. | `face.batch_updated` (with `action`, `face_count`, `person_id`) | Owner directive, Q-030-58 | +| FR-030-20 | When listing persons in the face assignment modal dropdown, each entry shall display a small circular face crop miniature (the representative crop from `PersonResource.representative_crop_url`) next to the person name, to disambiguate people with the same name. *(Q-030-59)* | FaceAssignmentModal dropdown uses PrimeVue Dropdown with custom `option` template: 24px circular `` (representative_crop_url) + person name + face count. Placeholder icon when no representative crop exists. | PersonResource already includes `representative_crop_url`. | — | — | Owner directive, Q-030-59 | +| FR-030-21 | When the photo details panel (sidebar) is open and the photo has detected faces, display circular face crop thumbnails with person name underneath. Click opens FaceAssignmentModal; CTRL+click dismisses the face (desktop only). Map the `P` key to toggle face overlay visibility. *(Q-030-60, Q-030-61, Q-030-65, Q-030-70, Q-030-71)* | New "People in this photo" section in PhotoDetails.vue. Horizontal scrollable flex row of circular face crops (48px diameter) with name label below each. Overflow: `overflow-x: auto`; when faces exceed visible width they are reachable by scrolling. Click → FaceAssignmentModal. CTRL+click (desktop only) → dismiss (same API as FR-030-16). `P` key confirmed free — `F` maps to fullscreen — toggles overlay visibility (stored in component state; default from `ai_vision_face_overlay_default_visibility` config). | Photo must have faces; face overlay feature must be enabled (`ai_vision_face_overlay_enabled = 1`). | — | — | Owner directive, Q-030-60, Q-030-61, Q-030-65, Q-030-70, Q-030-71 | +| FR-030-22 | New endpoint `GET /api/v2/Album/{id}/people` returns the list of persons found in the given album (via `photo_albums` pivot join). Response uses `PaginatedPersonsResource`. *(Q-030-62)* | Query: `SELECT DISTINCT persons.* FROM persons JOIN faces ON faces.person_id = persons.id JOIN photos ON faces.photo_id = photos.id JOIN photo_albums ON photo_albums.photo_id = photos.id WHERE photo_albums.album_id = ? AND faces.is_dismissed = false`. Respects `ai_vision_face_permission_mode` and `is_searchable` filtering. Paginated. | Album must exist; user must have access to the album. | 404 if album not found; 403 if unauthorized. | — | Owner directive, Q-030-62 | +| FR-030-23 | Maintenance page shall include a block for destroying all dismissed faces. The block only appears when dismissed faces exist (count > 0). *(Q-030-55)* | `GET /Maintenance::destroyDismissedFaces` (check): returns count of dismissed faces. `POST /Maintenance::destroyDismissedFaces` (do): calls existing `DELETE /Face/dismissed` logic. Maintenance card hidden when count is 0. | Admin-only. | — | `face.bulk_deleted` | Owner directive, Q-030-55 | +| FR-030-24 | Maintenance page shall include a single combined block for resetting photos with stuck-pending (`face_scan_status = 'pending'` older than 720 minutes) OR failed (`face_scan_status = 'failed'`) scan status, so they can be re-scanned. The block only appears when the combined count is > 0. *(Q-030-55, Q-030-73)* | `GET /Maintenance::resetFaceScanStatus` (check): returns combined count of stuck-pending (>720 min) + failed photos. `POST /Maintenance::resetFaceScanStatus` (do): sets `face_scan_status = NULL` on all stuck-pending (>720 min) and all failed photos in a single update. Block hidden when combined count is 0. The existing `Maintenance::resetStuckFaces` endpoint remains available for CLI use but is superseded in the UI by this combined block. | Admin-only. | — | `face.failed_scans_reset` (with `reset_count`) | Owner directive, Q-030-55, Q-030-73 | +| FR-030-25 | Person merge shall be accessible from the UI via a "Merge into..." button on the PersonDetail page. A modal allows searching/selecting the target person. *(Q-030-58)* | PersonDetail page includes a "Merge" action button. Clicking opens `MergePersonModal.vue` with a person search dropdown (same PrimeVue Dropdown + custom template with miniature as FR-030-20). User selects target, confirms → calls `POST /Person/{id}/merge`. Source person's page redirects to target person after merge. | Both persons must exist; user must have merge permission. | 403 if unauthorized; 404 if either person not found. | `person.merged` | Owner directive, Q-030-58 | ## Non-Functional Requirements @@ -77,9 +90,16 @@ Affected modules: **Models** (new Person, Face), **Migrations** (new tables + pi | Trigger scan | logged users | logged users | photo/album owner + admin | photo/album owner + admin | | Claim person | logged users | logged users | logged users | logged users | | Merge persons | logged users | logged users | photo/album owner + admin | admin only | +| Dismiss face | photo owner + admin | photo owner + admin | photo owner + admin | photo owner + admin | +| Batch face ops | logged users | logged users | photo/album owner + admin | admin only | +| View album people | album access | logged users | photo/album owner + admin | photo/album owner + admin | | NFR-030-08 | Python service accesses photos via shared Docker volume (filesystem path). Deployment must document volume mount configuration. *(Resolved Q-030-07)* | Performance; no auth complexity for file access. | Python service reads photos from shared path; integration test confirms file access. | Docker volume configuration in docker-compose. | Owner directive, Q-030-07 | | NFR-030-09 | AI Vision (facial recognition and People management) is a **Supporter Edition (SE)** feature. All AI Vision config keys are stored with `level = 1`; the admin settings UI hides them on non-SE instances. Face detection and People management endpoints return 403 on non-SE instances. | Licensing; feature differentiation. | Non-SE instance: settings page does not expose the AI Vision category; scan/people endpoints return 403. | License-level check middleware (existing SE gate); config `level` column. | Owner directive | +| NFR-030-10 | All `ai_vision_face_*` functionality is implicitly gated on `ai_vision_enabled = 1`. Any code path that checks `ai_vision_face_enabled` must first confirm `ai_vision_enabled = 1`. When `ai_vision_enabled = 0`, all AI Vision endpoints (face detection, People management, cluster review, selfie claim) return 503 and all AI Vision UI elements are hidden, regardless of the value of `ai_vision_face_enabled`. *(Q-030-51)* | Correctness; global kill-switch. | Feature tests confirm: `ai_vision_enabled=0` + `ai_vision_face_enabled=1` → all face endpoints inactive. | Compound gate check in FaceDetection and People controllers; `ai_vision_enabled` evaluated before `ai_vision_face_enabled` in every guard. | Owner directive, Q-030-51 | +| NFR-030-11 | Face overlay display is governed by two global config settings: `ai_vision_face_overlay_enabled` (0\|1, default 1) — master toggle for face overlay rendering; when 0, no face overlays or face circles are shown anywhere. `ai_vision_face_overlay_default_visibility` (string: `visible`\|`hidden`, default `visible`) — sets whether overlays are shown or hidden by default when a photo is viewed; user can toggle with `P` key (`P` confirmed unbound — `F` maps to fullscreen). Both settings are global (configs table), not per-user. *(Q-030-61, Q-030-65)* | UX flexibility; some users find overlays distracting. | Overlay disabled: feature test confirms no face DOM elements rendered. Toggle: pressing `P` flips overlay visibility. | Config migration, FaceOverlay.vue, PhotoDetails.vue. | Owner directive, Q-030-61, Q-030-65 | + +> **Policy refinement note *(Q-030-63)*:** The current four-level permission mode semantic (public/private/privacy-preserving/restricted) is correct. However, the policy also needs refinement with regard to album/photo edit rights — currently, "photo/album owner + admin" checks global ownership rather than per-resource ownership. This gap is acknowledged and will be revisited in a future iteration. For now, the focus is on UI/UX interaction. > **Cross-user reassignment *(Q-030-42)*:** The "Assign face" row governs reassignment of faces regardless of who previously assigned them. In `public`/`private` modes any user meeting the mode's write threshold may reassign; in `privacy-preserving`/`restricted` only the photo owner or admin may do so. @@ -111,6 +131,8 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | `ai_vision_face_person_is_searchable_default` | `0\|1` | `1` | Default `is_searchable` value assigned to each newly created Person record (FR-030-01). | | `ai_vision_face_allow_user_claim` | `0\|1` | `1` | When `1`, regular (non-admin) users may claim an assigned Person to link it to their account. Admins can always claim/unclaim regardless of this setting (FR-030-05). | | `ai_vision_face_scan_batch_size` | integer | `200` | Number of photo IDs dispatched per job chunk when bulk-scanning. Controls queue saturation — lower values reduce burst load at the cost of more job records. *(Q-030-45)* | +| `ai_vision_face_overlay_enabled` | `0\|1` | `1` | Master toggle for face overlay rendering. When `0`, no face overlays or face circles are shown anywhere in the UI. *(Q-030-61, NFR-030-11)* | +| `ai_vision_face_overlay_default_visibility` | enum string | `visible` | Default visibility state for face overlays when viewing a photo. Values: `visible`, `hidden`. User can toggle with `P` key during photo viewing. *(Q-030-61, NFR-030-11)* | ## UI / Interaction Mock-ups @@ -190,16 +212,20 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | └────┘ | | | | Assign to: | -| ○ Existing person: [ Alice ▼ ] | +| ○ Existing person: | +| [ (○) Alice 142ph ▼ ] | +| *(miniature + name + count)* | | ○ New person: [ __________ ] | | | | Similar faces found: | | [Alice (94%)] [Bob (12%)] | | | -| [Cancel] [Assign] | +| [Dismiss] [Cancel] [Assign] | +------------------------------------------+ ``` +*Note: The dropdown for existing persons shows a circular miniature (representative_crop_url) next to the name and face count to disambiguate same-name persons (FR-030-20). The "Dismiss" button calls `PATCH /Face/{id}` to mark `is_dismissed = true` (FR-030-16).* + ### Selfie Upload Claim ``` @@ -228,6 +254,171 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision +------------------------------------------+ ``` +### Cluster Review Page + +``` ++------------------------------------------------------------------+ +| Lychee > People > Clusters [Run Cluster] | ++------------------------------------------------------------------+ +| Showing 24 clusters of unassigned faces | ++------------------------------------------------------------------+ +| | +| Cluster #1 (12 faces) | +| +------+ +------+ +------+ +------+ +------+ [+7 more] | +| | | | | | | | | | | | +| | crop | | crop | | crop | | crop | | crop | | +| | | | | | | | | | | | +| +------+ +------+ +------+ +------+ +------+ | +| [ _______________ ] [Create Person & Assign All] [Dismiss] | +| | +| Cluster #2 (7 faces) | +| +------+ +------+ +------+ +------+ +------+ [+2 more] | +| | | | | | | | | | | | +| | crop | | crop | | crop | | crop | | crop | | +| | | | | | | | | | | | +| +------+ +------+ +------+ +------+ +------+ | +| [ _______________ ] [Create Person & Assign All] [Dismiss] | +| | +| Cluster #3 (3 faces) · · · | +| | +| [Load more clusters] | ++------------------------------------------------------------------+ +``` + +*Note: "Run Cluster" button triggers `POST /api/v2/Maintenance::runFaceClustering` then refreshes the page. Clusters with faces already assigned to a Person are not shown.* + +### Face Overlay — CTRL+Click Dismiss Mode *(FR-030-16)* + +``` ++------------------------------------------------------------------+ +| ◄ Photo Title ⋮ Menu | ++------------------------------------------------------------------+ +| | +| ┌──────────────────────────────────────────┐ | +| │ │ | +| │ ┌╌╌╌╌╌╌╌┐ ┌╌╌╌╌╌╌╌┐ │ | +| │ ╎ face1 ╎ ╎ face2 ╎ │ | +| │ ╎ RED ╎ ╎ RED ╎ │ | +| │ └╌╌╌╌╌╌╌┘ └╌╌╌╌╌╌╌┘ │ | +| │ │ | +| └──────────────────────────────────────────┘ | +| | +| [CTRL held — click face to dismiss] | ++------------------------------------------------------------------+ +``` + +*Note: When CTRL is held (desktop only — touch devices dismiss via modal button only), all face overlay rectangles switch to red dashed borders. Clicking directly dismisses the face without opening the modal. *(Q-030-70)*** + +### Photo Detail Panel — Face Circles *(FR-030-21)* + +``` ++------------------------------------------------------------------+ +| Photo Details Sidebar | ++------------------------------------------------------------------+ +| ┌────┐ | +| │ │ sunset_beach.jpg | +| │img │ 4032×3024 · 8.2 MB | +| └────┘ | +| ... | +| | +| People in this photo: | +| ┌──┐ ┌──┐ ┌──┐ | +| │○○│ │○○│ │○○│ | +| │○○│ │○○│ │○○│ | +| └──┘ └──┘ └──┘ | +| Alice Bob ??? | +| | +| [P] Toggle face overlay | +| ... | ++------------------------------------------------------------------+ +``` + +*Note: Circular face crop thumbnails (48px) with person name underneath. Horizontal scrollable row (`overflow-x: auto`). Click opens FaceAssignmentModal. CTRL+click (desktop only) dismisses. "???" for unassigned faces. *(Q-030-70, Q-030-71)*** + +### Cluster Review — Batch Select & Uncluster *(FR-030-17, FR-030-19)* + +``` ++------------------------------------------------------------------+ +| Lychee > People > Clusters [Run Cluster] | ++------------------------------------------------------------------+ +| Cluster #1 (12 faces) [Select Mode ✓] | +| +------+ +------+ +------+ +------+ +------+ [+7 more] | +| | ☑ | | ☐ | | ☑ | | ☐ | | ☐ | | +| | crop | | crop | | crop | | crop | | crop | | +| +------+ +------+ +------+ +------+ +------+ | +| ┌──────────────────────────────────────────────────────────┐ | +| │ 2 selected: [Uncluster] [Reassign to...] [Unassign] │ | +| └──────────────────────────────────────────────────────────┘ | +| [ _______________ ] [Create Person & Assign All] [Dismiss] | ++------------------------------------------------------------------+ +``` + +*Note: When "Select Mode" is active, checkbox overlays appear. Selected faces can be unclustered (removed from cluster), reassigned to another person, or unassigned.* + +### Person Detail — Batch Face Operations *(FR-030-19)* + +``` ++------------------------------------------------------------------+ +| Lychee > People > Alice [Select Mode ✓] | ++------------------------------------------------------------------+ +| ┌────┐ Alice · 142 photos | +| │crop│ [Edit] [Merge into...] [Delete] | +| └────┘ | ++------------------------------------------------------------------+ +| Faces assigned to Alice: | +| +------+ +------+ +------+ +------+ +------+ | +| | ☑ | | ☐ | | ☑ | | ☐ | | ☐ | | +| | crop | | crop | | crop | | crop | | crop | | +| +------+ +------+ +------+ +------+ +------+ | +| ┌──────────────────────────────────────────────────────────┐ | +| │ 2 selected: [Unassign] [Reassign to...] [New Person] │ | +| └──────────────────────────────────────────────────────────┘ | ++------------------------------------------------------------------+ +``` + +*Note: From a person's detail page, users can select faces and unassign them (return to unassigned pool), reassign to another person, or create a new person from the selection.* + +### Merge Person Modal *(FR-030-25)* + +``` ++------------------------------------------+ +| Merge Person | ++------------------------------------------+ +| | +| Merge "Bob" into: | +| | +| [ (○) Alice 142ph ▼ ] | +| *(search dropdown with miniature)* | +| | +| This will: | +| • Move all 87 faces from Bob to Alice | +| • Delete Bob's person record | +| • This action cannot be undone | +| | +| [Cancel] [Merge] | ++------------------------------------------+ +``` + +### Maintenance — Face Blocks *(FR-030-23, FR-030-24)* + +``` ++------------------------------------------------------------------+ +| Maintenance | ++------------------------------------------------------------------+ +| ... existing blocks ... | +| | +| ┌─────────────────────┐ ┌─────────────────────┐ | +| │ Destroy Dismissed │ │ Reset Face Scan │ | +| │ Faces │ │ Status │ | +| │ 23 dismissed faces │ │ 5 stuck + 3 failed │ | +| │ [Destroy All] │ │ [Reset All] │ | +| └─────────────────────┘ └─────────────────────┘ | +| | ++------------------------------------------------------------------+ +``` + +*Note: Each face maintenance block is conditional — hidden when its count is 0. The "Reset Face Scan Status" block combines stuck-pending (>720 min) and failed scans into one action. The existing `Maintenance::resetStuckFaces` API is retained for CLI use but has no dedicated UI card.* + ## Branch & Scenario Matrix | Scenario ID | Description / Expected outcome | @@ -255,6 +446,26 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | S-030-21 | User uploads selfie with no detectable face → 422 error | | S-030-22 | User uploads selfie but no matching Person found → 404, user informed | | S-030-23 | Photo uploaded with `ai_vision_face_enabled` → auto-scan job dispatched | +| S-030-27 | Admin triggers face clustering → Python runs DBSCAN across all stored embeddings → suggestion pairs bulk-upserted into `face_suggestions` → FaceAssignmentModal shows updated suggestions | +| S-030-28 | Admin hard-deletes all dismissed faces → Lychee deletes Face records → Python `DELETE /embeddings` called with their IDs → embeddings removed from store | +| S-030-29 | Photo deleted → Face records cascade-deleted → Python `DELETE /embeddings` called → stale embeddings removed | +| S-030-30 | Face detected in photo with sharpness below `VISION_FACE_BLUR_THRESHOLD` → face excluded from detection callback; not stored in Lychee or embedding store | +| S-030-31 | Admin opens Cluster Review page → clusters of visually similar unassigned faces displayed → admin names a cluster → new Person created and all faces in cluster assigned | +| S-030-32 | Admin dismisses an entire cluster from Cluster Review page → all faces in cluster marked `is_dismissed = true` | +| S-030-33 | User clicks "Dismiss" button in FaceAssignmentModal → face is dismissed (`is_dismissed = true`), modal closes, overlay removed | +| S-030-34 | User holds CTRL key → face overlay rectangles turn red/dashed → clicking a rectangle directly dismisses the face without opening modal | +| S-030-35 | User selects faces in cluster, clicks "Uncluster" → selected faces have `cluster_label` set to NULL, removed from cluster view | +| S-030-36 | User removes a face from a person (unassign) → face's `person_id` set to NULL, face returns to unassigned pool | +| S-030-37 | User selects multiple faces in person detail, clicks "Reassign to..." → all selected faces assigned to the chosen person | +| S-030-38 | Photo detail panel open with detected faces → circular face crops shown with names under; clicking opens FaceAssignmentModal | +| S-030-39 | CTRL+click on face circle in detail panel → face is dismissed directly | +| S-030-40 | User opens album → requests `GET /Album/{id}/people` → list of persons appearing in album photos is returned | +| S-030-41 | User presses `P` while viewing photo → face overlays toggle between visible and hidden | +| S-030-42 | Admin opens maintenance page with dismissed faces → "Destroy Dismissed Faces" block shown; clicks "Destroy All" → all dismissed faces hard-deleted | +| S-030-43 | Admin opens maintenance page with failed face scans → "Reset Failed Scans" block shown; clicks "Reset All" → failed photos reset to null | +| S-030-44 | `ai_vision_face_overlay_enabled` set to `0` → no face overlays or face circles rendered anywhere in the UI | +| S-030-45 | Person merge from UI: user opens PersonDetail → clicks "Merge into..." → selects target person with miniature → confirms → source person merged into target | +| S-030-46 | Face assignment dropdown shows persons with circular miniature + name + face count; two persons named "John" visually distinguishable | ## Test Strategy @@ -271,12 +482,14 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | ID | Description | Modules | |----|-------------|---------| -| DO-030-01 | `Person` — id, name, user_id (nullable, unique), is_searchable (boolean, default true), timestamps | Models, API, UI | -| DO-030-02 | `Face` — id, photo_id (FK→photos), person_id (nullable FK→persons), x, y, width, height (float 0.0–1.0), confidence (float 0.0–1.0), is_dismissed (boolean, default false), crop_token (random high-entropy token, nullable; file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx — path is unguessable, no app-level auth required), timestamps | Models, API, UI | -| DO-030-03 | `PersonResource` — Spatie Data resource for Person API responses | Resources | +| DO-030-01 | `Person` — id, name, user_id (nullable, unique), is_searchable (boolean, default true), representative_face_id (nullable FK→faces ON DELETE SET NULL — explicit override for the representative crop; if NULL, PersonResource falls back to highest-confidence non-dismissed face), timestamps | Models, API, UI | +| DO-030-02 | `Face` — id, photo_id (FK→photos), person_id (nullable FK→persons), x, y, width, height (float 0.0–1.0), confidence (float 0.0–1.0), is_dismissed (boolean, default false), crop_token (random high-entropy token, nullable; file stored at `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg` and served directly by nginx — path is unguessable, no app-level auth required), cluster_label (nullable INT; DBSCAN cluster assignment written by `POST /FaceDetection/cluster-results`; -1 = DBSCAN noise/outlier stored as NULL; NULL = not yet clustered), timestamps | Models, API, UI | +| DO-030-03 | `PersonResource` — Spatie Data resource for Person API responses. Fields: `id`, `name`, `user_id` (nullable), `is_searchable` (boolean), `face_count` (int), `photo_count` (int), `representative_face_id` (nullable string — echoes `persons.representative_face_id`), `representative_crop_url` (nullable string — computed: if `representative_face_id` is set and the referenced Face has a `crop_token`, use that face's crop URL; otherwise `SELECT crop_token FROM faces WHERE person_id = ? AND is_dismissed = false AND crop_token IS NOT NULL ORDER BY confidence DESC LIMIT 1`; null if no qualifying face exists). PATCH /Person/{id} accepts optional `representative_face_id` (string\|null) to explicitly set or clear the override. *(Q-030-50)* | Resources | | DO-030-04 | `FaceResource` — Spatie Data resource for Face API responses (included in PhotoResource). Fields exposed: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`, `y`, `width`, `height` (float 0.0–1.0), `confidence` (float 0.0–1.0), `is_dismissed` (boolean), `crop_url` (computed: `uploads/faces/{token[0:2]}/{token[2:4]}/{token}.jpg`; null if no crop). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's crop or null), `person_name` (nullable, resolved via LEFT JOIN on persons), `confidence` (float 0.0–1.0). Suggestions always embedded (not lazy-loaded) since they are pre-computed and stored — no N+1 risk. *(Q-030-46)* | Resources | | DO-030-05 | `FaceSuggestion` — face_id (FK→faces), suggested_face_id (FK→faces), confidence (float 0.0–1.0); pre-computed similar-face suggestions stored from Python scan callback. Both FKs point to `faces`; the assignment modal JOINs at read time to resolve `suggested_face_id → person_id` (supports unassigned suggestions). Unique constraint on `(face_id, suggested_face_id)`. *(Q-030-33)* | Models, API, UI | | DO-030-06 | `photos` table addendum — adds nullable `face_scan_status VARCHAR(16)` column; PHP `ScanStatus` Enum cast. Values: `null` (never scanned), `pending`, `completed`, `failed`. Type chosen for MySQL/PostgreSQL/SQLite portability. *(Q-030-38)* | Models, Migrations | +| DO-030-07 | `faces` table addendum — adds nullable `cluster_label INT` column (DBSCAN output label; NULL = not yet clustered or noise). Composite index `(cluster_label, person_id, is_dismissed)` on `faces` enables O(index-scan) `GROUP BY cluster_label` pagination for API-030-18. *(Q-030-49)* | Models, Migrations | +| DO-030-08 | `persons` table addendum — adds `representative_face_id` nullable FK→`faces` ON DELETE SET NULL. Separate migration required (cannot be included in the original `persons` migration because `faces` does not yet exist at that point — circular-FK dependency). *(Q-030-50)* | Models, Migrations | ### API Routes / Services @@ -297,9 +510,19 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | API-030-13 | POST /api/v2/Person/claim-by-selfie | Upload selfie photo, Python service matches against embeddings, link matching Person to current User | Multipart form with image file; **throttle: 5 requests/minute per user** *(Q-030-44)* | | API-030-14 | PATCH /api/v2/Face/{id} | Toggle `is_dismissed` flag (dismiss/undismiss a false-positive face) | Auth: photo owner or admin | | API-030-15 | DELETE /api/v2/Person/{id}/claim | Remove the User link from a Person (unclaim) | Auth: linked User or admin | -| API-030-16 | DELETE /api/v2/Face/dismissed | Admin: hard-delete all dismissed faces (is_dismissed = true) and their crop files | Admin-only *(Q-030-43)* | +| API-030-16 | DELETE /api/v2/Face/dismissed | Admin: hard-delete all dismissed faces (is_dismissed = true), their crop files, and their embeddings (FR-030-14: calls Python `DELETE /embeddings` asynchronously) | Admin-only *(Q-030-43)* | | API-030-17 | GET /api/v2/Maintenance::resetStuckFaces | Admin: check — returns count of photos stuck in `pending` longer than `older_than_minutes` (default 60). Follows existing check/do Maintenance pattern. | Admin-only *(Q-030-48)* | | API-030-17b | POST /api/v2/Maintenance::resetStuckFaces | Admin: do — reset all `face_scan_status = 'pending'` records older than threshold back to `null`. Body: optional `older_than_minutes` (integer, default 60). | Admin-only *(Q-030-48)* | +| API-030-18 | GET /api/v2/FaceDetection/clusters | List face clusters using `SELECT cluster_label, COUNT(*) as size FROM faces WHERE cluster_label IS NOT NULL AND person_id IS NULL AND is_dismissed = false GROUP BY cluster_label ORDER BY cluster_label LIMIT/OFFSET`. Paginated; each cluster: `{cluster_id: int, size: int, faces: FaceResource[]}` (faces loaded via separate WHERE cluster_label = ? query, limited to first N for preview). Composite index `(cluster_label, person_id, is_dismissed)` ensures O(index-scan). Respects `ai_vision_face_permission_mode` visibility rules. | Auth per permission mode | +| API-030-19 | POST /api/v2/FaceDetection/clusters/{cluster_id}/assign | Bulk-assign all qualifying faces (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`) to a Person (existing `person_id` or new `new_person_name`). Creates Person if `new_person_name` provided. `cluster_id` is the integer `cluster_label` value. | Body: `person_id` or `new_person_name` | +| API-030-20 | POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss | Bulk-dismiss all qualifying faces (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`) by setting `is_dismissed = true`. `cluster_id` is the integer `cluster_label` value. | Auth: photo owner or admin | +| API-030-21 | GET /api/v2/Maintenance::destroyDismissedFaces | Admin: check — returns count of dismissed faces (`is_dismissed = true`). Follows existing check/do Maintenance pattern. Hidden when count is 0. *(Q-030-55, FR-030-23)* | Admin-only | +| API-030-21b | POST /api/v2/Maintenance::destroyDismissedFaces | Admin: do — hard-delete all dismissed faces, their crop files, and their embeddings (same logic as API-030-16). Returns `{deleted_count: N}`. | Admin-only | +| API-030-22 | GET /api/v2/Maintenance::resetFaceScanStatus | Admin: check — returns combined count of stuck-pending photos (older than 720 min) + photos with `face_scan_status = 'failed'`. Hidden when count is 0. *(Q-030-55, Q-030-73, FR-030-24)* | Admin-only | +| API-030-22b | POST /api/v2/Maintenance::resetFaceScanStatus | Admin: do — sets `face_scan_status = NULL` on all stuck-pending (>720 min) AND all failed photos in one operation. Returns `{reset_count: N}`. Supersedes the UI role of `Maintenance::resetStuckFaces` (which remains for CLI use only). *(Q-030-73)* | Admin-only | +| API-030-23 | POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster | Remove selected faces from a cluster by setting `cluster_label = NULL`. Body: `{face_ids: [str]}`. Only affects qualifying faces in the given cluster (`cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`). Returns `{unclustered_count: int}`. *(FR-030-17, Q-030-56)* | Auth per permission mode | +| API-030-24 | POST /api/v2/Face/batch | Batch face operations. Body: `{face_ids: [str], action: "unassign"\|"assign", person_id?: str, new_person_name?: str}`. "unassign" sets `person_id = NULL`; "assign" sets `person_id` (or creates new Person). Returns `{affected_count: int, person_id?: str}`. *(FR-030-19, Q-030-58)* | Auth per permission mode; user must have assign permission for all faces | +| API-030-25 | GET /api/v2/Album/{id}/people | List distinct persons appearing in an album's photos. Joins `photo_albums → photos → faces → persons`. Returns `PaginatedPersonsResource`. Respects `ai_vision_face_permission_mode` and `is_searchable` filtering. *(FR-030-22, Q-030-62)* | Auth: album access required | ### CLI Commands / Flags @@ -327,6 +550,10 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | TE-030-10 | face.dismissed | `face_id`, `photo_id` *(Q-030-47)* | | TE-030-11 | face.undismissed | `face_id`, `photo_id` *(Q-030-47)* | | TE-030-12 | face.bulk_deleted | `deleted_count` (count of dismissed faces hard-deleted by API-030-16) *(Q-030-47)* | +| TE-030-13 | face.unclustered | `cluster_id`, `face_count` (faces removed from cluster via uncluster action) *(Q-030-56)* | +| TE-030-14 | face.unassigned | `face_id`, `previous_person_id` (face removed from person, returned to unassigned pool) *(Q-030-57)* | +| TE-030-15 | face.batch_updated | `action` (unassign\|assign), `face_count`, `person_id` (nullable) *(Q-030-58)* | +| TE-030-16 | face.failed_scans_reset | `reset_count` (number of failed-status photos reset to null) *(Q-030-55)* | ### UI States @@ -339,6 +566,14 @@ Read via `config('features.ai-vision.face-url')` and `config('features.ai-vision | UI-030-05 | Scan progress | Trigger scan → Progress indicator (scanning N of M photos) | | UI-030-06 | Service unavailable | Python service down → Toast notification, face features gracefully disabled | | UI-030-07 | Selfie upload claim | User profile → "Find me" button → Upload selfie → Matching result displayed → Confirm claim | +| UI-030-08 | CTRL+click dismiss mode | Hold CTRL (desktop only) → face overlays turn red/dashed → click to dismiss directly *(FR-030-16, Q-030-70)* | +| UI-030-09 | Person miniature in dropdown | Face assignment dropdown shows 24px circular miniature + name + face count per person; type-ahead filter *(FR-030-20, Q-030-69)* | +| UI-030-10 | Face circles in detail panel | Photo detail sidebar → "People in this photo" → horizontal scrollable row of 48px circular crops with name labels *(FR-030-21, Q-030-71)* | +| UI-030-11 | Face overlay visibility toggle | Press `P` (confirmed free; `F` = fullscreen) → face overlays toggle between visible and hidden *(NFR-030-11, Q-030-65)* | +| UI-030-12 | Batch face selection mode | Person detail or cluster view → "Select" button → checkbox overlays → action bar *(FR-030-19)* | +| UI-030-13 | Merge person modal | PersonDetail → "Merge into..." → modal with person search dropdown → confirm merge *(FR-030-25)* | +| UI-030-14 | Maintenance dismiss cleanup | Maintenance page → "Destroy Dismissed Faces" card (visible only when count > 0) *(FR-030-23)* | +| UI-030-15 | Maintenance reset face scan status | Maintenance page → "Reset Face Scan Status" card — combined reset for stuck-pending (>720 min) AND failed scans (visible only when combined count > 0) *(FR-030-24, Q-030-73)* | ## Telemetry & Observability @@ -496,6 +731,34 @@ routes: method: POST path: /api/v2/Maintenance::resetStuckFaces notes: admin-only; do — reset stuck pending photos to null; body: older_than_minutes (default 60) (Q-030-48) + - id: API-030-21 + method: GET + path: /api/v2/Maintenance::destroyDismissedFaces + notes: admin-only; check — count of dismissed faces (Q-030-55) + - id: API-030-21b + method: POST + path: /api/v2/Maintenance::destroyDismissedFaces + notes: admin-only; do — hard-delete all dismissed faces + crops + embeddings (Q-030-55) + - id: API-030-22 + method: GET + path: /api/v2/Maintenance::resetFaceScanStatus + notes: admin-only; check — combined count of stuck-pending (>720 min) + failed face scans (Q-030-55, Q-030-73) + - id: API-030-22b + method: POST + path: /api/v2/Maintenance::resetFaceScanStatus + notes: admin-only; do — reset both stuck-pending AND failed scans to null in one operation (Q-030-73) + - id: API-030-23 + method: POST + path: /api/v2/FaceDetection/clusters/{cluster_id}/uncluster + notes: remove selected faces from cluster (set cluster_label = NULL) (Q-030-56) + - id: API-030-24 + method: POST + path: /api/v2/Face/batch + notes: batch face operations — unassign or assign multiple faces (Q-030-58) + - id: API-030-25 + method: GET + path: /api/v2/Album/{id}/people + notes: list distinct persons in album photos (Q-030-62) cli_commands: - id: CLI-030-01 command: php artisan lychee:scan-faces @@ -533,6 +796,14 @@ telemetry_events: event: face.undismissed - id: TE-030-12 event: face.bulk_deleted + - id: TE-030-13 + event: face.unclustered + - id: TE-030-14 + event: face.unassigned + - id: TE-030-15 + event: face.batch_updated + - id: TE-030-16 + event: face.failed_scans_reset ui_states: - id: UI-030-01 description: People grid page @@ -548,6 +819,22 @@ ui_states: description: Service unavailable state - id: UI-030-07 description: Selfie upload claim modal + - id: UI-030-08 + description: CTRL+click dismiss mode on face overlays + - id: UI-030-09 + description: Person miniature in assignment dropdown + - id: UI-030-10 + description: Face circles in photo detail panel + - id: UI-030-11 + description: Face overlay visibility toggle (P key) + - id: UI-030-12 + description: Batch face selection mode + - id: UI-030-13 + description: Merge person modal + - id: UI-030-14 + description: Maintenance dismiss cleanup card + - id: UI-030-15 + description: Maintenance reset failed scans card ``` ## Appendix @@ -654,6 +941,18 @@ Lychee resolves `lychee_face_id → Face → person_id` to identify the matching **Health Check:** `GET /health` → `{"status": "ok"}` +**Embedding Deletion (Lychee → Python):** `DELETE /embeddings` *(FR-030-14)* +```json +{ + "face_ids": ["face_abc123", "face_def456"] +} +``` +Response (200): +```json +{ "deleted_count": 2 } +``` +Lychee dispatches this call as a fire-and-forget queued job after hard-deleting Face records (dismissed bulk-delete or Photo cascade). The Python service removes the listed embeddings from `EmbeddingStore`; IDs not found are silently ignored. Endpoint authenticated via `X-API-Key`. + ### Python Service Technical Specification #### Technology Stack @@ -961,7 +1260,9 @@ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers ${VI | `VISION_FACE_API_KEY` | Yes | — | Shared API key used in both directions: validates inbound `X-API-Key` from Lychee scan requests; also sent as `X-API-Key` on callbacks to Lychee. Must match `AI_VISION_FACE_API_KEY` on the Lychee side. | | `VISION_FACE_MODEL_NAME` | No | `buffalo_l` | InsightFace model pack name | | `VISION_FACE_DETECTION_THRESHOLD` | No | `0.5` | Bounding box confidence filter — faces below threshold excluded from callback *(Q-030-31)* | +| `VISION_FACE_BLUR_THRESHOLD` | No | `100.0` | Laplacian variance sharpness filter — faces whose crop Laplacian variance falls below this value are excluded as too blurry for reliable recognition (FR-030-02). Set to `0.0` to disable blur filtering. | | `VISION_FACE_MATCH_THRESHOLD` | No | `0.5` | Similarity score cutoff for suggestions and selfie match results *(Q-030-31)* | +| `VISION_FACE_CLUSTER_EPS` | No | `0.6` | DBSCAN epsilon (maximum cosine distance between embeddings to be considered the same cluster) used by `POST /cluster` (FR-030-13). Lower values → tighter clusters. | | `VISION_FACE_RESCAN_IOU_THRESHOLD` | No | `0.5` | IoU threshold for bounding-box matching on re-scan (preserves `person_id`) *(Q-030-35)* | | `VISION_FACE_MAX_FACES_PER_PHOTO` | No | `10` | Maximum faces included in the callback payload (top-N by confidence; rest dropped) *(Q-030-39)* | | `VISION_FACE_THREAD_POOL_SIZE` | No | `1` | CPU-bound inference thread pool size (`asyncio.run_in_executor`) *(Q-030-26)* | diff --git a/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md b/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md index 13e112ce3d6..9c1f6cc9dc1 100644 --- a/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md +++ b/docs/specs/4-architecture/features/030-ai-vision-service/tasks.md @@ -1,7 +1,7 @@ # Feature 030 Tasks – Facial Recognition _Status: Draft_ -_Last updated: 2026-03-21_ +_Last updated: 2026-04-04_ > Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). > **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. @@ -12,7 +12,7 @@ _Last updated: 2026-03-21_ ### I1 – Python Service: Project Setup & Face Detection -- [ ] T-030-01 – Create Python service project structure with uv, ruff, ty. +- [x] T-030-01 – Create Python service project structure with uv, ruff, ty. _Intent:_ Create `ai-vision-service/` directory with: `pyproject.toml` (uv project config, ruff settings, ty config), `app/` (main application with `__init__.py`), `app/detection/`, `app/embeddings/`, `app/api/`, `app/clustering/`, `app/matching/`, `tests/`, `Dockerfile`, `README.md`. Configure ruff lint rules (E, W, F, I, N, UP, ANN, B, A, SIM, TCH, RUF) and ty in `pyproject.toml`. Create Pydantic `AppSettings` (BaseSettings) in `app/config.py` with all `VISION_FACE_`-prefixed env vars. Create Pydantic request/response schemas in `app/api/schemas.py`: `DetectRequest`, `FaceResult`, `DetectCallbackPayload`, `MatchResult`, `MatchResponse`, `HealthResponse`. All code fully type-annotated. _Verification commands:_ - `uv sync` @@ -20,13 +20,13 @@ _Last updated: 2026-03-21_ - `uv run ruff check` - `uv run ty check` -- [ ] T-030-02 – Implement face detection and crop generation with InsightFace. +- [x] T-030-02 – Implement face detection and crop generation with InsightFace. _Intent:_ `app/detection/detector.py`: typed wrapper around InsightFace (ONNX Runtime backend, `buffalo_l` model). Accept photo filesystem path (shared Docker volume — Q-030-07 resolved), return list of `FaceResult` with bounding box coordinates as 0.0–1.0 relative values and confidence scores. `app/detection/cropper.py`: generate 150x150px JPEG face crop per detected face using Pillow, returned as base64 string (Q-030-09 resolved: server-side crop). Full type annotations; no `Any` types. _Verification commands:_ - `uv run pytest tests/test_detection.py tests/test_cropper.py` - `uv run ty check` -- [ ] T-030-03 – Implement embedding generation and storage layer. +- [x] T-030-03 – Implement embedding generation and storage layer. _Intent:_ `app/embeddings/store.py`: abstract `EmbeddingStore` protocol (typed). `app/embeddings/sqlite_store.py`: SQLite+sqlite-vec implementation. `app/embeddings/pgvector_store.py`: PostgreSQL+pgvector implementation. CRUD operations for embeddings. Vector similarity search for matching. Configurable via `VISION_FACE_STORAGE_BACKEND` env var. Pydantic validation on all inputs. _Verification commands:_ - `uv run pytest tests/test_embeddings.py` @@ -34,19 +34,19 @@ _Last updated: 2026-03-21_ ### I2 – Python Service: Clustering, Matching & Callback -- [ ] T-030-04 – Implement face clustering with scikit-learn DBSCAN. +- [x] T-030-04 – Implement face clustering with scikit-learn DBSCAN. _Intent:_ `app/clustering/clusterer.py`: cluster similar face embeddings using scikit-learn DBSCAN. Configurable distance threshold (eps). Returns cluster labels for each embedding. Typed interface. No need to pre-specify cluster count (Q-030-03 resolved: auto-cluster with manual confirmation). _Verification commands:_ - `uv run pytest tests/test_clustering.py` - `uv run ty check` -- [ ] T-030-05 – Implement similarity matching. +- [x] T-030-05 – Implement similarity matching. _Intent:_ `app/matching/matcher.py`: `POST /match` endpoint logic (Q-030-12 resolved: dedicated endpoint). Accepts image file (multipart via FastAPI `UploadFile`), detects face, compares embedding against stored embeddings via `EmbeddingStore.similarity_search()`, returns list of `MatchResult` with confidence scores. Selfie image discarded after match — no temp file persisted (Q-030-11 resolved). Full type annotations. _Verification commands:_ - `uv run pytest tests/test_matching.py` - `uv run ty check` -- [ ] T-030-06 – Implement FastAPI REST API, scan callback flow, and API key auth. +- [x] T-030-06 – Implement FastAPI REST API, scan callback flow, and API key auth. _Intent:_ `app/main.py`: FastAPI app factory with lifespan handler (model loading on startup). `app/api/routes.py`: `POST /detect`, `POST /match`, `GET /health` — all using Pydantic request/response models. `app/api/dependencies.py`: API key auth as FastAPI dependency (validates `X-API-Key` header against `VISION_FACE_API_KEY`). Scan callback flow: receive `DetectRequest` → detect faces → generate embeddings + base64 crops → store embeddings → POST `DetectCallbackPayload` back to Lychee via httpx. `HealthResponse` includes model_loaded status and embedding_count. _Verification commands:_ - `uv run pytest tests/test_api.py` @@ -56,18 +56,18 @@ _Last updated: 2026-03-21_ ### I3 – Python Service: Docker Image, Deployment & CI/CD -- [ ] T-030-07 – Create Dockerfile and docker-compose integration. +- [x] T-030-07 – Create Dockerfile and docker-compose integration. _Intent:_ Multi-stage Dockerfile: builder stage uses `uv sync --frozen --no-dev`, runtime stage uses `python:3.13-slim`. Minimal image size. GPU support optional. Model (`buffalo_l`) baked into image at build time — lifespan handler loads it on startup, no runtime download (Q-030-32 resolved). Workers count via CMD shell form to honour `VISION_FACE_WORKERS`. All env vars `VISION_FACE_`-prefixed (see Pydantic `AppSettings`). Add service to Lychee's docker-compose example with shared photos volume and internal network. _Verification commands:_ - `docker build -t lychee-ai-vision .` - `docker-compose up -d` -- [ ] T-030-08 – Create GitHub Actions CI/CD workflow for Python service. +- [x] T-030-08 – Create GitHub Actions CI/CD workflow for Python service. _Intent:_ `.github/workflows/python_ai_vision.yml`: triggers on push/PR when `ai-vision-service/**` changes. Jobs: lint (`uv run ruff format --check`, `uv run ruff check`), typecheck (`uv run ty check`), test (`uv run pytest --cov=app --cov-report=xml`, Python 3.13+3.14 matrix), docker-build (`docker build .`). Uses `astral-sh/setup-uv@v5`. Follow existing Lychee CI patterns: pinned action versions, `step-security/harden-runner`, concurrency groups. _Verification commands:_ - Push branch and verify workflow runs green -- [ ] T-030-09 – End-to-end smoke test in Docker. +- [x] T-030-09 – End-to-end smoke test in Docker. _Intent:_ docker-compose up → health check passes → detect endpoint responds → callback delivers results to mock Lychee endpoint. Verify shared volume photo access works. _Verification commands:_ - Integration test suite @@ -76,50 +76,56 @@ _Last updated: 2026-03-21_ ### I4 – Database Migrations -- [ ] T-030-10 – Create `persons` table migration (FR-030-01, S-030-01). - _Intent:_ Migration with columns: id (string PK), name (varchar 255), user_id (nullable unsigned int, unique, FK→users ON DELETE SET NULL), is_searchable (boolean default true), timestamps. Index on user_id. +- [x] T-030-10 – Create `persons` table migration (FR-030-01, S-030-01). + _Intent:_ Migration with columns: id (string PK), name (varchar 255), user_id (nullable unsigned int, unique, FK→users ON DELETE SET NULL), is_searchable (boolean default true), timestamps. Index on user_id. **Does not include `representative_face_id`** — that FK references the `faces` table which does not yet exist; it is added in a separate addendum migration (T-030-53, DO-030-08) after `faces` is created, to avoid a circular FK dependency. _Verification commands:_ - `php artisan test` _Notes:_ Use string PK consistent with Photo/Album models. -- [ ] T-030-11 – Create `faces` table migration and `face_suggestions` table migration (FR-030-02, DO-030-02, DO-030-05, Q-030-33/34/38). +- [x] T-030-11 – Create `faces` table migration and `face_suggestions` table migration (FR-030-02, DO-030-02, DO-030-05, Q-030-33/34/38). _Intent:_ `faces` migration: `id` (string PK), `photo_id` (FK→photos CASCADE), `person_id` (nullable FK→persons SET NULL), `x`/`y`/`width`/`height` (float, 0.0–1.0), `confidence` (float), `crop_token` (nullable string — random high-entropy token; file served nginx-direct from `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`, Q-030-34), `is_dismissed` (boolean default false), timestamps. Indexes on `photo_id`, `person_id`. `face_suggestions` migration: `face_id` (FK→faces CASCADE), `suggested_face_id` (FK→faces CASCADE), `confidence` (float 0.0–1.0); unique on `(face_id, suggested_face_id)`. Separate migration: add nullable `face_scan_status VARCHAR(16)` column to `photos` table (Q-030-38). _Verification commands:_ - `php artisan test` _Notes:_ Bounding box values are relative (0.0–1.0) per NFR-030-06. `crop_path` is NOT a column — `crop_url` is a computed accessor derived from `crop_token`. -- [ ] T-030-12 – Add AI Vision config entries migration (FR-030-07, FR-030-08, NFR-030-09). +- [x] T-030-12 – Add AI Vision config entries migration (FR-030-07, FR-030-08, NFR-030-09). _Intent:_ Config entries in `configs` table (`cat = 'AI Vision'`, `level = 1` / Supporter Edition): `ai_vision_enabled` (0|1, default 0), `ai_vision_face_enabled` (0|1, default 0), `ai_vision_face_permission_mode` (string, default `restricted`), `ai_vision_face_selfie_confidence_threshold` (float, default 0.8), `ai_vision_face_person_is_searchable_default` (0|1, default 1), `ai_vision_face_allow_user_claim` (0|1, default 1), `ai_vision_face_scan_batch_size` (integer, default 200). Infrastructure secrets (`AI_VISION_FACE_URL`, `AI_VISION_FACE_API_KEY`) added to `config/features.php` — NOT in the `configs` table. _Verification commands:_ - `php artisan test` +- [x] T-030-53 – Add `representative_face_id` column migration to `persons` table (DO-030-08, Q-030-50). + _Intent:_ New migration (runs **after** the `faces` table migration from T-030-11): `ALTER TABLE persons ADD COLUMN representative_face_id VARCHAR(?) NULL`. Add FK constraint: `representative_face_id` → `faces.id` ON DELETE SET NULL. This resolves the circular-FK dependency (persons→faces and faces→persons both require the other table to exist first). Run tests to confirm migration applies cleanly on SQLite. + _Verification commands:_ + - `php artisan test` + - `make phpstan` + ### I5 – Eloquent Models & Relationships -- [ ] T-030-13 – Write unit tests for Person model relationships (FR-030-01, FR-030-03, S-030-17). +- [x] T-030-13 – Write unit tests for Person model relationships (FR-030-01, FR-030-03, S-030-17). _Intent:_ Test Person→User (belongsTo), Person→Faces (hasMany), Person→Photos (derived through Face). Test cascade: Photo delete → Face cascade delete. Test Person delete → Face.person_id set to null. _Verification commands:_ - `php artisan test --filter=PersonModelTest` - `make phpstan` -- [ ] T-030-14 – Write unit tests for Face model relationships (FR-030-02, FR-030-04). +- [x] T-030-14 – Write unit tests for Face model relationships (FR-030-02, FR-030-04). _Intent:_ Test Face→Photo (belongsTo), Face→Person (belongsTo nullable). Test bounding box validation (0.0–1.0 range). Test `crop_url` accessor (computed from `crop_token`). _Verification commands:_ - `php artisan test --filter=FaceModelTest` - `make phpstan` -- [ ] T-030-15 – Implement Person model (FR-030-01, FR-030-03). +- [x] T-030-15 – Implement Person model (FR-030-01, FR-030-03). _Intent:_ Eloquent model with: `user()` belongsTo, `faces()` hasMany, `photos()` custom relation via Face→Photo, `scopeSearchable()` query scope for is_searchable filtering. Fillable: name, user_id, is_searchable. _Verification commands:_ - `php artisan test --filter=PersonModelTest` - `make phpstan` -- [ ] T-030-16 – Implement Face model (FR-030-02). +- [x] T-030-16 – Implement Face model (FR-030-02). _Intent:_ Eloquent model with: `photo()` belongsTo, `person()` belongsTo (nullable). `crop_url` computed accessor (from `crop_token`). Fillable: photo_id, person_id, x, y, width, height, confidence, crop_token, is_dismissed. Casts for float fields. _Verification commands:_ - `php artisan test --filter=FaceModelTest` - `make phpstan` -- [ ] T-030-17 – Add `faces()`, `faceSuggestions()` relationships to Photo model; `person()` to User model; `ScanStatus` Enum cast (FR-030-04, FR-030-05, Q-030-38). +- [x] T-030-17 – Add `faces()`, `faceSuggestions()` relationships to Photo model; `person()` to User model; `ScanStatus` Enum cast (FR-030-04, FR-030-05, Q-030-38). _Intent:_ Photo hasMany Face; User hasOne Person. Add `ScanStatus` PHP Backed Enum (values: `pending`, `completed`, `failed`) and cast `face_scan_status` via it on the Photo model. FaceSuggestion Eloquent model: `face()` / `suggestedFace()` belongsTo Face; `confidence` float; fillable `[face_id, suggested_face_id, confidence]`. *(DO-030-05, DO-030-06)* _Verification commands:_ - `php artisan test --filter=PersonModelTest` @@ -128,21 +134,21 @@ _Last updated: 2026-03-21_ ### I6 – Spatie Data Resources -- [ ] T-030-18 – Create PersonResource and FaceResource (DO-030-03, DO-030-04, Q-030-46). - _Intent:_ PersonResource: `id`, `name`, `user_id`, `is_searchable`, `face_count` (int), `photo_count` (int), `representative_crop_url`. FaceResource per DO-030-04: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`/`y`/`width`/`height` (float 0.0–1.0), `confidence`, `is_dismissed`, `crop_url` (computed from `crop_token`: `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`; null if no crop). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's own crop or null), `person_name` (nullable, LEFT JOIN), `confidence`. Suggestions always included (pre-computed from `face_suggestions` table, no N+1 risk). Include FaceResource array in PhotoResource with `hidden_face_count` (int, count of suppressed non-searchable faces — Q-030-10). +- [x] T-030-18 – Create PersonResource and FaceResource (DO-030-03, DO-030-04, Q-030-46). + _Intent:_ PersonResource: `id`, `name`, `user_id`, `is_searchable`, `face_count` (int), `photo_count` (int), `representative_face_id` (nullable string), `representative_crop_url` (nullable string — computed: if `representative_face_id` is set and the referenced Face has a `crop_token`, use that face's crop URL; otherwise `SELECT crop_token FROM faces WHERE person_id = ? AND is_dismissed = false AND crop_token IS NOT NULL ORDER BY confidence DESC LIMIT 1`; null if no qualifying face). FaceResource per DO-030-04: `id` (Face ID), `photo_id`, `person_id` (nullable), `x`/`y`/`width`/`height` (float 0.0–1.0), `confidence`, `is_dismissed`, `crop_url` (computed from `crop_token`: `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`; null if no crop). Embedded `suggestions[]` array — each item: `suggested_face_id`, `crop_url` (suggested face's own crop or null), `person_name` (nullable, LEFT JOIN), `confidence`. Suggestions always included (pre-computed from `face_suggestions` table, no N+1 risk). Include FaceResource array in PhotoResource with `hidden_face_count` (int, count of suppressed non-searchable faces — Q-030-10). _Verification commands:_ - `make phpstan` _Notes:_ Follow existing Spatie Data patterns in app/Http/Resources/. ### I7 – Person CRUD Endpoints -- [ ] T-030-19 – Write feature tests for Person CRUD and non-searchable filtering (FR-030-01, FR-030-06, S-030-05, S-030-15, S-030-18). +- [x] T-030-19 – Write feature tests for Person CRUD and non-searchable filtering (FR-030-01, FR-030-06, S-030-05, S-030-15, S-030-18). _Intent:_ Tests for: list persons (paginated), get person, create person, update person (name, is_searchable), delete person (face.person_id nullified). Non-searchable person hidden from non-admin non-linked users. Admin sees all. Test both `open` and `restricted` permission modes (Q-030-08 resolved). Verify hidden_face_count in photo detail response. _Verification commands:_ - `php artisan test --filter=PeopleControllerTest` - `make phpstan` -- [ ] T-030-20 – Implement PeopleController with CRUD actions (API-030-01 through API-030-05). +- [x] T-030-20 – Implement PeopleController with CRUD actions (API-030-01 through API-030-05). _Intent:_ index (paginated, searchable scope), show, store, update, destroy. Form requests: StorePersonRequest (name required, user_id optional unique), UpdatePersonRequest (name, is_searchable). Permission mode middleware/gate: check `ai_vision_face_permission_mode` config. Routes in api_v2.php. _Verification commands:_ - `php artisan test --filter=PeopleControllerTest` @@ -150,47 +156,47 @@ _Last updated: 2026-03-21_ ### I8 – Person Claim, Admin Override, Merge & Selfie Claim -- [ ] T-030-21 – Write feature tests for Person claim, admin override, and merge (FR-030-05, FR-030-11, S-030-04, S-030-13, S-030-16, S-030-19). +- [x] T-030-21 – Write feature tests for Person claim, admin override, and merge (FR-030-05, FR-030-11, S-030-04, S-030-13, S-030-16, S-030-19). _Intent:_ Tests: claim person (success, sets user_id), claim already-claimed (409), admin force-claim (overrides existing link), unclaim. Merge: faces reassigned from source to target, source deleted, face count updated. Test both permission modes. _Verification commands:_ - `php artisan test --filter=PersonClaimTest` - `php artisan test --filter=PersonMergeTest` - `make phpstan` -- [ ] T-030-22 – Implement claim (user + admin override) and merge actions (API-030-06, API-030-07). - _Intent:_ ClaimPerson action: set person.user_id to Auth::id(), enforce uniqueness for non-admin. Admin claim: override existing link (clear previous user's claim, set new). MergePerson action: reassign Face records, delete source Person. Register routes. +- [x] T-030-22 – Implement claim (user + admin override), unclaim, and merge actions (API-030-06, API-030-07, API-030-15). + _Intent:_ ClaimPerson action: set person.user_id to Auth::id(), enforce uniqueness for non-admin. Admin claim: override existing link (clear previous user's claim, set new). UnclaimPerson action: `DELETE /api/v2/Person/{id}/claim` — sets `person.user_id = null`; only linked User or admin can unclaim (FR-030-05). MergePerson action: reassign Face records, delete source Person. Register all three routes. _Verification commands:_ - `php artisan test --filter=PersonClaimTest` - `php artisan test --filter=PersonMergeTest` - `make phpstan` -- [ ] T-030-23 – Write feature tests for selfie-upload claim (FR-030-12, S-030-20, S-030-21, S-030-22). +- [x] T-030-23 – Write feature tests for selfie-upload claim (FR-030-12, S-030-20, S-030-21, S-030-22). _Intent:_ Tests: upload selfie → Python service returns match → Person linked (success); selfie with no face detected (422); no matching Person (404); matched Person already claimed by another user (409). Verify selfie image discarded after match (Q-030-11 resolved). _Verification commands:_ - `php artisan test --filter=SelfieClaimTest` - `make phpstan` -- [ ] T-030-24 – Implement SelfieClaimController (API-030-13). - _Intent:_ POST /Person/claim-by-selfie: accepts multipart image upload, sends to Python service `POST /match` (Q-030-12 resolved: dedicated endpoint), receives matching person_id + confidence, validates confidence ≥ `ai_vision_face_selfie_confidence_threshold`, links Person to User (same 1-1 rules), deletes temp selfie. Register route. +- [x] T-030-24 – Implement SelfieClaimController (API-030-13). + _Intent:_ POST /Person/claim-by-selfie: accepts multipart image upload, sends to Python service `POST /match` (Q-030-12 resolved: dedicated endpoint), receives matching person_id + confidence, validates confidence ≥ `ai_vision_face_selfie_confidence_threshold`, links Person to User (same 1-1 rules), deletes temp selfie. Apply Laravel `throttle:5,1` middleware to this route (5 requests/minute per user — Q-030-44). Register route. _Verification commands:_ - `php artisan test --filter=SelfieClaimTest` - `make phpstan` ### I9 – Face Assignment, Dismiss & Cleanup Endpoints -- [ ] T-030-25 – Write feature tests for face assignment (FR-030-10, S-030-02, S-030-03). +- [x] T-030-25 – Write feature tests for face assignment (FR-030-10, S-030-02, S-030-03). _Intent:_ Tests: assign face to existing person, assign face creating new person, reassign face to different person. Test all four `ai_vision_face_permission_mode` values. _Verification commands:_ - `php artisan test --filter=FaceAssignmentTest` - `make phpstan` -- [ ] T-030-26 – Write feature tests for face dismiss/undismiss and admin bulk delete (API-030-14, API-030-16, S-030-24, S-030-25, Q-030-47). +- [x] T-030-26 – Write feature tests for face dismiss/undismiss and admin bulk delete (API-030-14, API-030-16, S-030-24, S-030-25, Q-030-47). _Intent:_ Tests: `PATCH /api/v2/Face/{id}` toggles `is_dismissed`; photo owner can dismiss, non-owner gets 403; admin can always dismiss. `DELETE /api/v2/Face/dismissed` hard-deletes all `is_dismissed = true` faces, removes crop files, returns count. Emit `face.dismissed` / `face.undismissed` / `face.bulk_deleted` telemetry events (TE-030-10/11/12). _Verification commands:_ - `php artisan test --filter=FaceDismissTest` - `make phpstan` -- [ ] T-030-26b – Implement FaceController: `assign`, `toggleDismissed`, and `destroyDismissed` actions (API-030-09, API-030-14, API-030-16). +- [x] T-030-26b – Implement FaceController: `assign`, `toggleDismissed`, and `destroyDismissed` actions (API-030-09, API-030-14, API-030-16). _Intent:_ `POST /Face/{id}/assign`: accepts `person_id` OR `new_person_name`; creates Person if needed; updates `face.person_id`. `PATCH /Face/{id}`: flips `is_dismissed`; auth: photo owner or admin; emits `face.dismissed` or `face.undismissed`. `DELETE /Face/dismissed`: admin-only; loops `is_dismissed = true` faces, deletes crop files from `uploads/faces/`, deletes Face records, emits `face.bulk_deleted` with count. Create form requests: `AssignFaceRequest`, `ToggleDismissedRequest`. Register routes. _Verification commands:_ - `php artisan test --filter=FaceAssignmentTest` @@ -199,19 +205,19 @@ _Last updated: 2026-03-21_ ### I10 – Scan Trigger & Result Ingestion Endpoints -- [ ] T-030-27 – Write feature tests for scan trigger and result ingestion (FR-030-07, FR-030-08, S-030-01, S-030-07, S-030-08, S-030-14, S-030-23). +- [x] T-030-27 – Write feature tests for scan trigger and result ingestion (FR-030-07, FR-030-08, S-030-01, S-030-07, S-030-08, S-030-14, S-030-23). _Intent:_ Tests: trigger scan for photo (202), trigger scan for album, receive results (Face records created with crop_token), re-scan replaces old faces (old crops deleted), invalid photo_id (404), auto-scan on upload when enabled. Test both permission modes for scan trigger. _Verification commands:_ - `php artisan test --filter=FaceDetectionTest` - `make phpstan` -- [ ] T-030-28 – Write feature test for service unavailability (FR-030-08, NFR-030-03, S-030-09). +- [x] T-030-28 – Write feature test for service unavailability (FR-030-08, NFR-030-03, S-030-09). _Intent:_ Test: scan trigger when Python service is unreachable returns 503; all other Lychee endpoints continue to work. _Verification commands:_ - `php artisan test --filter=FaceDetectionServiceUnavailableTest` - `make phpstan` -- [ ] T-030-29 – Implement FaceDetectionController, DispatchFaceScanJob, ProcessFaceDetectionResults, and auto-on-upload hook (API-030-10, API-030-11, API-030-12, S-030-23, Q-030-28/33/34/35/45). +- [x] T-030-29 – Implement FaceDetectionController, DispatchFaceScanJob, ProcessFaceDetectionResults, and auto-on-upload hook (API-030-10, API-030-11, API-030-12, S-030-23, Q-030-28/33/34/35/45). _Intent:_ `scan` action: validate target (`photo_ids[]` or `album_id`), set `face_scan_status = pending`, dispatch DispatchFaceScanJob in chunks of `ai_vision_face_scan_batch_size` (default 200, Q-030-45), return 202. Job sends HTTP `POST /detect` with `photo_path` (filesystem via shared volume) — **no `callback_url` in body** (Python reads callback URL from `VISION_FACE_LYCHEE_API_URL` env, Q-030-28). `results` action: validate X-API-Key; on success — decode base64 crops, store at `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`, create Face records with `crop_token`, create/replace FaceSuggestion rows from `suggestions[]` (Q-030-33), IoU-match old faces on re-scan to preserve `person_id` (Q-030-14; threshold from `VISION_FACE_RESCAN_IOU_THRESHOLD`, Q-030-35), set `face_scan_status = completed`; on error — set `face_scan_status = failed` (Q-030-17). `bulk-scan` action: enqueue photos where `face_scan_status IS NULL` (Q-030-40/41). Auto-on-upload: listener on PhotoSaved event dispatches job when `ai_vision_face_enabled = 1`. _Verification commands:_ - `php artisan test --filter=FaceDetection` @@ -219,25 +225,25 @@ _Last updated: 2026-03-21_ ### I11 – Bulk Scan Commands & Maintenance Endpoints -- [ ] T-030-30 – Write feature tests for `lychee:scan-faces` command (FR-030-09, S-030-06, CLI-030-01, CLI-030-02). +- [x] T-030-30 – Write feature tests for `lychee:scan-faces` command (FR-030-09, S-030-06, CLI-030-01, CLI-030-02). _Intent:_ Tests: command enqueues photos where `face_scan_status IS NULL` (not failed/completed), `--album` filter works (non-recursive — only direct photos in album, Q-030-41), already-scanned photos skipped. _Verification commands:_ - `php artisan test --filter=ScanFacesCommandTest` - `make phpstan` -- [ ] T-030-31 – Implement `lychee:scan-faces` and `lychee:scan-faces --album={id}` commands (CLI-030-01, CLI-030-02). +- [x] T-030-31 – Implement `lychee:scan-faces` and `lychee:scan-faces --album={id}` commands (CLI-030-01, CLI-030-02). _Intent:_ Query photos where `face_scan_status IS NULL`, dispatch DispatchFaceScanJob. `--album={id}` limits to direct photos in that album (non-recursive). Progress output per batch. _Verification commands:_ - `php artisan test --filter=ScanFacesCommandTest` - `make phpstan` -- [ ] T-030-31b – Implement `lychee:rescan-failed-faces [--stuck-pending] [--older-than=N]` command (CLI-030-03, Q-030-40/48). +- [x] T-030-31b – Implement `lychee:rescan-failed-faces [--stuck-pending] [--older-than=N]` command (CLI-030-03, Q-030-40/48). _Intent:_ Default: re-enqueue all photos where `face_scan_status = 'failed'`. With `--stuck-pending`: additionally reset photos with `face_scan_status = 'pending'` and `updated_at < now() - N minutes` (default `--older-than=60`) back to `null`, making them eligible for a fresh scan. *(Q-030-48)* _Verification commands:_ - `php artisan test --filter=RescanFailedFacesCommandTest` - `make phpstan` -- [ ] T-030-31c – Write feature tests and implement Maintenance::resetStuckFaces endpoints (API-030-17, API-030-17b, Q-030-48, S-030-26). +- [x] T-030-31c – Write feature tests and implement Maintenance::resetStuckFaces endpoints (API-030-17, API-030-17b, Q-030-48, S-030-26). _Intent:_ `GET /api/v2/Maintenance::resetStuckFaces`: admin-only check — returns `{count: N}` for photos stuck in `pending` longer than `older_than_minutes` (default 60). `POST /api/v2/Maintenance::resetStuckFaces`: admin-only do — resets those records to `null` and returns `{reset_count: N}`. Body: optional `older_than_minutes` (integer). Follows existing `Maintenance::cleaning` / `Maintenance::jobs` check/do pattern. Register in `api_v2.php`. _Verification commands:_ - `php artisan test --filter=MaintenanceResetStuckFacesTest` @@ -245,13 +251,13 @@ _Last updated: 2026-03-21_ ### I12 – Person Photos Endpoint -- [ ] T-030-32 – Write feature test for Person photos listing (FR-030-03, S-030-12, API-030-08). +- [x] T-030-32 – Write feature test for Person photos listing (FR-030-03, S-030-12, API-030-08). _Intent:_ Tests: get paginated photos for person, respects album access control (user without album access doesn't see photo), empty result for person with no faces. _Verification commands:_ - `php artisan test --filter=PersonPhotosTest` - `make phpstan` -- [ ] T-030-33 – Implement PersonPhotosController (API-030-08). +- [x] T-030-33 – Implement PersonPhotosController (API-030-08). _Intent:_ GET /Person/{id}/photos: paginated photos through Face join, apply PhotoQueryPolicy for access control. Register route. _Verification commands:_ - `php artisan test --filter=PersonPhotosTest` @@ -261,7 +267,7 @@ _Last updated: 2026-03-21_ ### I13 – Frontend: People Page -- [ ] T-030-34 – Create People.vue, PeopleService.ts, and PersonCard.vue (UI-030-01). +- [x] T-030-34 – Create People.vue, PeopleService.ts, and PersonCard.vue (UI-030-01). _Intent:_ People page at /people route. Grid of PersonCard components (server-side face crop thumbnail from crop_url, name, photo count). PeopleService: getPeople(), getPerson(), etc. Empty state when no persons exist. Service unavailable state (toast notification). Navigation link in sidebar. _Verification commands:_ - `npm run check` @@ -269,7 +275,7 @@ _Last updated: 2026-03-21_ ### I14 – Frontend: Person Detail Page -- [ ] T-030-35 – Create PersonDetail.vue (UI-030-02). +- [x] T-030-35 – Create PersonDetail.vue (UI-030-02). _Intent:_ Person detail at /people/:id. Person info header (name, counts, linked user, searchability badge). Paginated photo grid (reuse existing layout components). Action buttons: Edit, Toggle searchable, Merge, Delete. Route registration. _Verification commands:_ - `npm run check` @@ -277,7 +283,7 @@ _Last updated: 2026-03-21_ ### I15 – Frontend: Face Overlays on Photo Detail -- [ ] T-030-36 – Create FaceOverlay.vue and integrate into photo detail (UI-030-03). +- [x] T-030-36 – Create FaceOverlay.vue and integrate into photo detail (UI-030-03). _Intent:_ Positioned div overlays on photo using bounding box percentages (x, y, width, height as CSS left/top/width/height %). Name label per overlay. "Unknown" for unassigned faces. Non-searchable faces: overlays hidden entirely; display "{N} face(s) hidden for privacy" message when `hidden_face_count > 0` (Q-030-10 resolved). Click unassigned → open assignment modal. Responsive scaling with image container. _Verification commands:_ - `npm run check` @@ -285,7 +291,7 @@ _Last updated: 2026-03-21_ ### I16 – Frontend: Face Assignment Modal -- [ ] T-030-37 – Create FaceAssignmentModal.vue (UI-030-04). +- [x] T-030-37 – Create FaceAssignmentModal.vue (UI-030-04). _Intent:_ Modal triggered by clicking unassigned face overlay. Face crop preview (from crop_url), confidence display. PrimeVue Dropdown to select existing person (with filter). Text input for new person name. Calls FaceService.assign() on confirm. Refreshes face overlays after success. _Verification commands:_ - `npm run check` @@ -293,7 +299,7 @@ _Last updated: 2026-03-21_ ### I17 – Frontend: Scan Trigger UI -- [ ] T-030-38 – Add scan trigger buttons to photo/album context menus and admin page (UI-030-05, UI-030-06). +- [x] T-030-38 – Add scan trigger buttons to photo/album context menus and admin page (UI-030-05, UI-030-06). _Intent:_ "Scan for faces" in photo context menu (calls FaceDetectionService.scan). "Scan album" in album context menu. "Bulk scan all photos" in admin Maintenance page. Progress toast during scanning. Graceful handling when service unavailable. _Verification commands:_ - `npm run check` @@ -301,7 +307,7 @@ _Last updated: 2026-03-21_ ### I18 – Frontend: Selfie Upload Claim -- [ ] T-030-39 – Create SelfieClaimModal.vue and integrate into user profile (UI-030-07, S-030-20, S-030-21, S-030-22). +- [x] T-030-39 – Create SelfieClaimModal.vue and integrate into user profile (UI-030-07, S-030-20, S-030-21, S-030-22). _Intent:_ Modal with file upload area (drag & drop or click) for selfie image. Sends to API-030-13. Displays matching Person result (face crop, name, confidence score). Confirm button links Person to User. Error states: no face detected, no match found, already claimed. "Find me in photos" button on user profile page triggers modal. _Verification commands:_ - `npm run check` @@ -311,47 +317,226 @@ _Last updated: 2026-03-21_ ### I19 – Documentation & Quality Gate -- [ ] T-030-40 – Update knowledge-map.md with Person/Face models and service integration. +- [x] T-030-40 – Update knowledge-map.md with Person/Face models and service integration. _Intent:_ Add Person, Face to Domain Layer models. Add Python face-recognition service to Dependencies. Add inter-service communication to Architectural Patterns. Add shared Docker volume architecture. _Verification commands:_ - Review documentation for accuracy. -- [ ] T-030-41 – Update database-schema.md with persons and faces tables. +- [x] T-030-41 – Update database-schema.md with persons and faces tables. _Intent:_ Add table definitions (including `crop_token` on faces, `face_suggestions` table, `face_scan_status` on photos), relationships, indexes, and constraints. _Verification commands:_ - Review documentation for accuracy. -- [ ] T-030-42 – Create configure-facial-recognition.md how-to guide. +- [x] T-030-42 – Create configure-facial-recognition.md how-to guide. _Intent:_ Docker setup instructions, shared volume configuration, environment variables, permission modes (open/restricted), service health check, troubleshooting. _Verification commands:_ - Review documentation for accuracy. -- [ ] T-030-43 – Run full quality gate and update roadmap. - _Intent:_ Run all quality gates across all three codebases. All green. Update roadmap status to Complete. +### I20 – Clustering Endpoint: Python `POST /cluster` + PHP Ingestion & Trigger + +- [x] T-030-43 – Add `cluster_label` column migration to `faces` table (DO-030-07, Q-030-49). + _Intent:_ New migration: `ALTER TABLE faces ADD COLUMN cluster_label INT NULL`. Add composite index `(cluster_label, person_id, is_dismissed)` on `faces` to support O(index-scan) `GROUP BY cluster_label` paging in API-030-18. This migration is a prerequisite for T-030-44 (Python cluster ingestion) and T-030-50 (cluster-review API). Run tests to confirm migration applies cleanly on SQLite. _Verification commands:_ - - Python: `cd ai-vision-service && uv run ruff format --check && uv run ruff check && uv run ty check && uv run pytest --cov=app` - - PHP: `vendor/bin/php-cs-fixer fix && php artisan test && make phpstan` - - Frontend: `npm run format && npm run check` + - `php artisan test` + - `make phpstan` + +- [x] T-030-44 – Implement Python `POST /cluster` endpoint and wire `FaceClusterer` (FR-030-13, S-030-27, Q-030-49). + _Intent:_ Add `ClusterResponse` Pydantic schema (`{clusters: int, faces_labeled: int, suggestions_generated: int}`) to `app/api/schemas.py`. Add `VISION_FACE_CLUSTER_EPS` (float, default `0.6`) to `AppSettings`. Extend `app/clustering/clusterer.py` with `run_cluster_and_notify(store, lychee_url, api_key)`: read all embeddings, run DBSCAN, produce (a) `labels` list — `[{face_id: str, cluster_label: int}]` for every non-noise face (noise faces skipped); (b) `suggestions` list — `(face_id, suggested_face_id, confidence)` pairs for every intra-cluster pair (cosine similarity); POST `{labels: [...], suggestions: [...]}` to `{lychee_url}/api/v2/FaceDetection/cluster-results` with `X-API-Key`. Add `POST /cluster` route to `app/api/routes.py` (X-API-Key auth dependency) that calls `run_cluster_and_notify()` and returns `ClusterResponse`. Add unit + integration tests in `tests/test_clustering.py` (mock httpx POST to Lychee). + _Verification commands:_ + - `cd ai-vision-service && uv run pytest tests/test_clustering.py` + - `uv run ty check` + - `uv run ruff check` + +- [x] T-030-45 – Write feature tests and implement PHP `POST /FaceDetection/cluster-results` ingestion endpoint (FR-030-13, Q-030-49). + _Intent:_ New action `clusterResults` on `FaceDetectionController`: auth via X-API-Key header; validate body `{labels: [{face_id: str, cluster_label: int}], suggestions: [{face_id: str, suggested_face_id: str, confidence: float}]}` (both arrays optional — empty = no-op for that field). Processing: (1) if `labels` non-empty — first reset all `faces.cluster_label` to NULL (full re-cluster run), then bulk `UPDATE faces SET cluster_label = ? WHERE id = ?` for each label entry; (2) if `suggestions` non-empty — bulk-upsert `face_suggestions` rows on `(face_id, suggested_face_id)` updating `confidence`. Return `{faces_labeled: N, suggestions_updated: M}`. Feature tests: labels bulk-update `faces.cluster_label` correctly; suggestions upserted; both arrays in same request; invalid API key (401); malformed body (422); unknown face_id (422). Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=FaceClusterResultsTest` + - `make phpstan` + +- [x] T-030-46 – Write feature tests and implement PHP `POST /Maintenance::runFaceClustering` admin trigger (FR-030-13). + _Intent:_ Admin-only Maintenance endpoint following the existing check/do pattern. `POST /api/v2/Maintenance::runFaceClustering`: calls Python service `POST /cluster` via HTTP with `X-API-Key`; returns 202 Accepted on success; returns 503 if Python service is unreachable (NFR-030-03). Feature tests: admin triggers clustering (202), non-admin gets 403, service unavailable (503). Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=MaintenanceFaceClusteringTest` + - `make phpstan` + +### I21 – Embedding Sync on Deletion + Blur Threshold Filtering + +- [x] T-030-47 – Python: `VISION_FACE_BLUR_THRESHOLD` filter in detector (FR-030-02, S-030-30). + _Intent:_ Add `VISION_FACE_BLUR_THRESHOLD` (float, default `100.0`) to `AppSettings` in `app/config.py`. In `app/detection/detector.py`, after detecting each face and cropping the bounding-box region, compute its Laplacian variance (`cv2.Laplacian(crop_region, cv2.CV_64F).var()`); discard any face whose variance is below the threshold before adding it to the results list. A value of `0.0` disables the filter (all faces pass). Update `tests/test_detection.py`: verify a synthetic blurry patch (Gaussian blur, variance << threshold) is excluded; verify a sharp patch is retained. + _Verification commands:_ + - `cd ai-vision-service && uv run pytest tests/test_detection.py` + - `uv run ty check` + - `uv run ruff check` + +- [x] T-030-48 – Python: `DELETE /embeddings` endpoint (FR-030-14, S-030-28, S-030-29). + _Intent:_ Add `delete_many(face_ids: list[str]) -> int` to the `EmbeddingStore` protocol in `app/embeddings/store.py` and implement it in both `SQLiteStore` and `PgVectorStore` (ignores unknown IDs silently; returns count of rows actually deleted). Add `DELETE /embeddings` route in `app/api/routes.py` (X-API-Key auth): accepts `{face_ids: list[str]}`, calls `store.delete_many()`, returns `{deleted_count: int}`. Add tests in `tests/test_api.py`: success (returns count), invalid API key (401), empty list (400), IDs not in store (returns `{deleted_count: 0}`). + _Verification commands:_ + - `cd ai-vision-service && uv run pytest tests/test_api.py tests/test_embeddings.py` + - `uv run ty check` + - `uv run ruff check` + +- [x] T-030-49 – PHP: dispatch embedding deletion after Face hard-deletes (FR-030-14, S-030-28, S-030-29). + _Intent:_ Create `DeleteFaceEmbeddingsJob` (implements `ShouldQueue`): accepts `array $faceIds`, calls Python `DELETE /embeddings` via HTTP with `X-API-Key`; catches all exceptions, logs a warning (`Log::warning`), and returns without re-throwing (never fails the queue worker or rolls back the Lychee deletion). Dispatch this job from **two explicit call-sites** (no Face model observer — Q-030-52/Option B): (1) in `destroyDismissed` action — collect IDs of dismissed faces before `Face::where('is_dismissed', true)->delete()`, then dispatch job; (2) in `PhotoObserver::deleting` — collect `$photo->faces()->pluck('id')` before cascade delete, then dispatch batch job for those IDs. Write feature tests: `DELETE /Face/dismissed` → job dispatched with correct IDs; Photo delete → job dispatched for cascaded faces; Python service unavailable → Lychee deletion succeeds, warning logged. + _Verification commands:_ + - `php artisan test --filter=FaceEmbeddingSyncTest` + - `make phpstan` + +### I22 – Cluster Review UI: Browse & Bulk-Name/Dismiss Clusters + +- [x] T-030-50 – Write feature tests and implement PHP cluster-review API endpoints (FR-030-15, API-030-18, API-030-19, API-030-20, S-030-31, S-030-32, Q-030-49). + _Intent:_ `GET /api/v2/FaceDetection/clusters`: `SELECT cluster_label, COUNT(*) as size FROM faces WHERE cluster_label IS NOT NULL AND person_id IS NULL AND is_dismissed = false GROUP BY cluster_label ORDER BY cluster_label LIMIT ? OFFSET ?` (uses composite index DO-030-07); for each cluster, load preview faces via `WHERE cluster_label = ? AND person_id IS NULL AND is_dismissed = false`; return `{cluster_id: int, size: int, faces: FaceResource[]}`. Respects `ai_vision_face_permission_mode`. `POST /api/v2/FaceDetection/clusters/{cluster_id}/assign`: validate `cluster_id` is a valid integer `cluster_label` with qualifying faces; create Person if `new_person_name` supplied (or validate existing `person_id`); bulk `UPDATE faces SET person_id = ? WHERE cluster_label = ? AND person_id IS NULL AND is_dismissed = false`; emit `face.cluster_assigned`; return `{person_id, assigned_count}`. `POST /api/v2/FaceDetection/clusters/{cluster_id}/dismiss`: bulk `UPDATE faces SET is_dismissed = true WHERE cluster_label = ? AND person_id IS NULL AND is_dismissed = false`; emit `face.cluster_dismissed`; return `{dismissed_count}`. Feature tests: list clusters (only qualifying faces; already-assigned or dismissed excluded), 404 for unknown cluster_id, assign cluster (new person + faces linked; existing person used if person_id supplied), dismiss cluster (all qualifying faces marked is_dismissed). Test permission mode enforcement (public/restricted at minimum). Register routes in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=FaceClusterReviewTest` + - `make phpstan` + +- [x] T-030-51 – Create FaceClusterService.ts, FaceClusters.vue page, and wire into navigation (FR-030-15, UI-030-08, S-030-31, S-030-32). + _Intent:_ Create `services/FaceClusterService.ts` with typed functions: `getClusters(page)`, `assignCluster(clusterId, payload)`, `dismissCluster(clusterId)`, `runClustering()` — all using `${Constants.getApiUrl()}` base URL. New Vue3 view `FaceClusters.vue` at `/people/clusters`. Fetches `GET /FaceDetection/clusters` (paginated, via FaceClusterService). Renders a vertical list of cluster cards; each card shows: first 5 face-crop `` thumbnails (from `crop_url`), "+N more" badge when `size > 5`, size badge, a name `InputText`, a "Create Person & Assign All" `Button` (calls `assignCluster` with `new_person_name`; or if person selected from dropdown, uses `person_id`), and a "Dismiss" `Button` (calls `dismissCluster`). After either action, remove the cluster card from the list without full-page reload. "Run Cluster" `Button` in page header calls `runClustering()` then re-fetches clusters. Empty state illustration when no clusters exist. Add `/people/clusters` route to Vue Router and a "Clusters" navigation link under People in the sidebar (visible only when `ai_vision_face_enabled` is true). + _Verification commands:_ + - `npm run check` + - `npm run format` + +### Phase 5: Face UX Enhancements + +### I23 – Face Dismiss UX: Modal Button + CTRL+Click Overlay + +- [ ] T-030-54 – Add "Dismiss" button to FaceAssignmentModal.vue (FR-030-16, S-030-33). + _Intent:_ Add a "Dismiss" button to FaceAssignmentModal.vue (alongside existing "Cancel" and "Assign" buttons). Clicking "Dismiss" calls `PATCH /Face/{id}` to set `is_dismissed = true`, closes the modal, and refreshes the face overlay on the photo. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [ ] T-030-55 – Implement CTRL+click dismiss shortcut on FaceOverlay.vue (FR-030-16, S-030-34, UI-030-08, Q-030-70). + _Intent:_ In FaceOverlay.vue, **first** check `isTouchDevice()` from `keybindings-utils.ts` — if true, skip all CTRL+click setup (Q-030-70: B, no touch shortcut). On non-touch devices: listen for CTRL `keydown`/`keyup` events on `window`. When CTRL is held: (a) switch all face rectangle CSS to red dashed borders (`border: 2px dashed red`); (b) change cursor to `crosshair` to indicate dismiss action. When a rectangle is clicked in CTRL state: call `PATCH /Face/{id}` directly (no modal), remove the overlay element on success, show success toast. When CTRL is released, revert to normal overlay styles. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I24 – Face Overlay Config Settings & P-Key Toggle + +- [ ] T-030-56 – Add config migration for face overlay settings (NFR-030-11, S-030-44). + _Intent:_ Add two new config entries to the AI Vision category: `ai_vision_face_overlay_enabled` (0|1, default 1, level 1/SE) — master toggle for face overlay rendering; `ai_vision_face_overlay_default_visibility` (string: `visible`|`hidden`, default `visible`, level 1/SE) — default visibility when viewing photos. + _Verification commands:_ + - `php artisan test` + - `make phpstan` + +- [ ] T-030-57 – Implement face overlay config gating and P-key toggle in FaceOverlay.vue (NFR-030-11, FR-030-21, S-030-41, UI-030-11, Q-030-65). + _Intent:_ Gate FaceOverlay rendering on `ai_vision_face_overlay_enabled` config (if 0, render nothing). Initialize overlay visibility from `ai_vision_face_overlay_default_visibility` config. Register `P` key handler using `onKeyStroke('p', ...)` from `@vueuse/core` with `shouldIgnoreKeystroke()` guard — `P` is **confirmed free** (confirmed in Q-030-65: `F` maps to fullscreen via `Album.vue` `onKeyStroke("f", ...)`). + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I25 – Face Circles in Photo Detail Panel + +- [ ] T-030-58 – Add "People in this photo" section to PhotoDetails.vue (FR-030-21, S-030-38, S-030-39, UI-030-10, Q-030-70, Q-030-71). + _Intent:_ Add a new section titled "People in this photo" in `PhotoDetails.vue` (photo detail sidebar). Render a horizontal flex row (`overflow-x: auto`) of circular face crop `` elements (48px diameter, `border-radius: 50%`, `object-fit: cover`) sourced from `FaceResource.crop_url`. Below each circle, show person name (`person_name` from FaceResource or "???" for unassigned). Section hidden when: no faces detected, `ai_vision_face_overlay_enabled = 0`, or `ai_vision_enabled = 0`. Click on a face circle → emit event to open FaceAssignmentModal for that face. CTRL+click (desktop only, checked via `isTouchDevice()`) → call `PATCH /Face/{id}` to dismiss (same pattern as I23; no touch shortcut per Q-030-70). Overflow handled by horizontal scroll — all circles accessible by scrolling, no "+N more" truncation needed (Q-030-71: A). + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I26 – Batch Face Operations: API + Frontend + +- [ ] T-030-59 – Implement `POST /api/v2/Face/batch` endpoint (FR-030-19, API-030-24, S-030-37). + _Intent:_ New endpoint in FaceController. Body: `{face_ids: string[], action: "unassign"|"assign", person_id?: string, new_person_name?: string}`. For "unassign": bulk `UPDATE faces SET person_id = NULL WHERE id IN (...)`. For "assign": if `person_id` provided, validate it exists; if `new_person_name` provided, create new Person. Then bulk `UPDATE faces SET person_id = ? WHERE id IN (...)`. Auth: check assign permission for every face. Return `{affected_count: int, person_id?: string}`. Emit `face.batch_updated` telemetry. Create `BatchFaceRequest` form request. + _Verification commands:_ + - `php artisan test --filter=FaceBatchTest` + - `make phpstan` + +- [ ] T-030-60 – Implement `POST /api/v2/FaceDetection/clusters/{cluster_id}/uncluster` endpoint (FR-030-17, API-030-23, S-030-35). + _Intent:_ New endpoint in FaceClusterController. Body: `{face_ids: string[]}`. Sets `cluster_label = NULL` for faces matching: `id IN (face_ids) AND cluster_label = cluster_id AND person_id IS NULL AND is_dismissed = false`. Returns `{unclustered_count: int}`. Emit `face.unclustered` telemetry. Create `UnclusterFacesRequest` form request. Register route. + _Verification commands:_ + - `php artisan test --filter=FaceUnclusterTest` + - `make phpstan` + +- [ ] T-030-61 – Write feature tests for batch face operations and uncluster (FR-030-17, FR-030-19). + _Intent:_ Tests: batch unassign (person_id set to NULL on selected faces), batch assign to existing person, batch assign creating new person, uncluster faces from cluster (cluster_label set to NULL), auth checks (unauthorized user → 403), invalid face_ids (422), empty face_ids (422). + _Verification commands:_ + - `php artisan test --filter=FaceBatch` + - `php artisan test --filter=FaceUncluster` + - `make phpstan` + +- [ ] T-030-62 – Implement batch selection mode in PersonDetail.vue and FaceClusters.vue (FR-030-19, UI-030-12). + _Intent:_ Add "Select" toggle button to PersonDetail.vue (face grid section) and FaceClusters.vue (each cluster card). When active: checkbox overlay appears on each face crop thumbnail. Selecting faces shows an action bar at the bottom: "Unassign (N)" (PersonDetail only), "Reassign to..." (opens person search dropdown), "Assign to new person" (text input), "Uncluster" (FaceClusters only). Each action calls the corresponding API endpoint. After action, deselect all and refresh the view. Create `FaceBatchService.ts` with typed functions: `batchUpdate(faceIds, action, personId?, newPersonName?)`, `unclusterFaces(clusterId, faceIds)`. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I27 – Maintenance Blocks: Dismiss Cleanup + Reset Failed/Stuck Scans + +- [ ] T-030-63 – Implement `DestroyDismissedFaces` maintenance controller (FR-030-23, API-030-21/21b, S-030-42). + _Intent:_ New maintenance controller class following the existing check/do pattern. `check(MaintenanceRequest)`: returns count of `Face::where('is_dismissed', true)->count()`. Returns 0 if AI Vision is not enabled. `do(MaintenanceRequest)`: reuse `destroyDismissed` logic — collect dismissed face IDs, delete crop files, delete Face records, dispatch `DeleteFaceEmbeddingsJob`, return `{deleted_count}`. Register `GET`/`POST` routes as `Maintenance::destroyDismissedFaces` in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=MaintenanceDestroyDismissedFacesTest` + - `make phpstan` + +- [ ] T-030-64 – Implement `ResetFaceScanStatus` combined maintenance controller (FR-030-24, API-030-22/22b, S-030-43, Q-030-73). + _Intent:_ New maintenance controller class `ResetFaceScanStatus` following check/do pattern. Combines stuck-pending AND failed resets into one block (Q-030-73: group together). `check(MaintenanceRequest)`: returns combined count — stuck-pending (older than 720 min: `face_scan_status = PENDING AND updated_at < now() - 720min`) + failed (`face_scan_status = FAILED`). Returns 0 if AI Vision is not enabled. `do(MaintenanceRequest)`: single DB operation that resets both: `Photo::where(fn → face_scan_status=FAILED OR (face_scan_status=PENDING AND updated_at < cutoff))->update(['face_scan_status' => null])`. Returns `{reset_count: N}`. Emit `face.failed_scans_reset` telemetry. Register routes as `Maintenance::resetFaceScanStatus` in `api_v2.php`. The existing `ResetStuckFaces.php` controller remains (unchanged) for CLI use. + _Verification commands:_ + - `php artisan test --filter=MaintenanceResetFailedFaceScansTest` + - `make phpstan` + +- [ ] T-030-65 – Create maintenance Vue components for dismiss cleanup and combined scan reset (UI-030-14, UI-030-15, Q-030-73). + _Intent:_ Create `MaintenanceDestroyDismissedFaces.vue`: follows existing `MaintenanceBulkScanFaces.vue` pattern. On mount, calls `GET /Maintenance::destroyDismissedFaces` check endpoint. If count is 0, component renders nothing (v-if). If count > 0, shows card with count and "Destroy All" button. Button calls `POST /Maintenance::destroyDismissedFaces`, refreshes count on success. Create `MaintenanceResetFaceScanStatus.vue` (NOT separate stuck/failed cards — combined per Q-030-73): same pattern, calls `GET/POST /Maintenance::resetFaceScanStatus`, label describes "stuck and failed scans". Add **both** components (two total, not three) to `Maintenance.vue` template alongside existing face maintenance blocks. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I28 – Merge Person UI + Person Miniature in Dropdown + +- [ ] T-030-66 – Create MergePersonModal.vue (FR-030-25, S-030-45, UI-030-13). + _Intent:_ New modal component `MergePersonModal.vue`. Props: `sourcePerson` (PersonResource). Content: header "Merge {source.name} into:", PrimeVue Dropdown with person search (same custom option template as T-030-67 — miniature + name + count), filter by typing. Warning text explaining merge consequences (face count moved, source deleted, irreversible). Cancel/Merge buttons. On confirm, call `POST /Person/{source.id}/merge` with `{source_person_id: source.id}` (note: URL `{id}` = target, body = source). After success, navigate to target person page. Add "Merge into..." button to PersonDetail.vue actions, gated on merge permission. + _Verification commands:_ + - `npm run check` + - `npm run format` + +- [ ] T-030-67 – Add person miniature to FaceAssignmentModal dropdown (FR-030-20, S-030-46, UI-030-09). + _Intent:_ Update the existing person Dropdown in FaceAssignmentModal.vue to use a custom `option` template (PrimeVue `#option` slot). Each option renders: 24px circular `` (`border-radius: 50%`, `object-fit: cover`) from `person.representative_crop_url`, person name, face count in muted text. Fallback: placeholder person icon (PrimeVue `pi pi-user` or similar) when `representative_crop_url` is null. Reuse this template pattern in MergePersonModal.vue. + _Verification commands:_ + - `npm run check` + - `npm run format` + +### I29 – Album People Endpoint + +- [ ] T-030-68 – Implement `GET /api/v2/Album/{id}/people` endpoint (FR-030-22, API-030-25, S-030-40). + _Intent:_ New `AlbumPeopleController` with `index()` action. Query joins `photo_albums → photos → faces → persons` to collect distinct persons in the album (non-recursive, direct photos only). Apply `ai_vision_face_permission_mode` visibility rules and `is_searchable` filtering. Return `PaginatedPersonsResource` (consistent with People listing). Create `AlbumPeopleRequest` form request: validate album_id exists, user has album access (use existing album access policy). Register route in `api_v2.php`. + _Verification commands:_ + - `php artisan test --filter=AlbumPeopleTest` + - `make phpstan` + +- [ ] T-030-69 – Write feature tests for album people endpoint (FR-030-22, S-030-40). + _Intent:_ Tests: album with persons returns correct distinct list, album with no faces returns empty, non-searchable person filtered out for non-admin, user without album access gets 403, album not found returns 404, pagination works correctly. Test with photos linked via `photo_albums` pivot table. + _Verification commands:_ + - `php artisan test --filter=AlbumPeopleTest` + - `make phpstan` + +### I30 – Unassign Face from Person + +- [ ] T-030-70 – Update face assign endpoint to support unassign (FR-030-18, S-030-36). + _Intent:_ Update `POST /Face/{id}/assign` in FaceController to accept `person_id: null` (or omitted `person_id` with neither `person_id` nor `new_person_name` present, treated as unassign). Sets `face.person_id = NULL`. Emit `face.unassigned` telemetry with `previous_person_id`. Update `AssignFaceRequest` validation to allow nullable `person_id`. Write feature test: assign a face, then unassign it; verify face is in unassigned state. + _Verification commands:_ + - `php artisan test --filter=FaceAssignment` + - `make phpstan` + +- [ ] T-030-71 – Add "Remove from person" UI in PersonDetail.vue (FR-030-18). + _Intent:_ In PersonDetail.vue face grid (non-batch mode), add a small "×" remove button (or right-click context menu) on each face crop. Clicking calls `POST /Face/{id}/assign` with `person_id: null`. After success, remove the face from the grid and update the face count. + _Verification commands:_ + - `npm run check` + - `npm run format` ## Notes / TODOs -**All Q-030-01 through Q-030-48 have been resolved.** All decisions are encoded in spec.md normative sections. No blocking questions remain. - -**Previously blocking items — now resolved:** -- Q-030-13: `lychee_face_id` returned by `/match`; used in selfie claim flow. *(resolved, I8)* -- Q-030-14: Re-scan IoU-matches old faces to preserve `person_id`; configurable via `VISION_FACE_RESCAN_IOU_THRESHOLD`. *(resolved, I10)* -- Q-030-15: Single shared symmetric API key, both directions via `X-API-Key` header. *(resolved, I3/I10)* -- Q-030-16: `is_dismissed` boolean on Face; dismiss via `PATCH /Face/{id}`, hard-delete via `DELETE /Face/dismissed`. *(resolved, I9)* -- Q-030-17: Error callback payload defined (`ErrorCallbackPayload`); sets `face_scan_status = failed`. *(resolved, I10)* -- Q-030-18: Face.person_id type is `string` (consistent with string PKs). *(resolved, no code impact)* -- Q-030-19: `VISION_FACE_*` env prefix; `ai_vision_face_*` / `ai_vision_*` config keys. *(resolved, I3/I4)* -- Q-030-20: Four-mode permission matrix defined. *(resolved, I7)* -- Q-030-21: `DELETE /Person/{id}/claim` unclaim endpoint. *(resolved, I8)* -- Q-030-22: `{id}` = target Person (kept); `source_person_id` in body. *(resolved, I8)* -- Q-030-23: State machine documented; `face_scan_status VARCHAR(16)` + ScanStatus enum cast. *(resolved, I4/I10)* -- Q-030-24: Suggestions pre-computed via NN cosine similarity search (Python side); stored in `face_suggestions` table; embedded in FaceResource. *(resolved, I2/I10/I6)* -- Q-030-25: crop stored at `uploads/faces/{tok[0:2]}/{tok[2:4]}/{tok}.jpg`; served nginx-direct. *(resolved, I10)* -- Q-030-26: ThreadPoolExecutor concurrency model in Python. *(resolved, I2)* -- Q-030-27: Fire-and-forget callback; stuck-pending recovery via CLI-030-03 `--stuck-pending` and Maintenance endpoint. *(resolved, I11)* -- Q-030-28: `callback_url` removed from DetectRequest body; Python reads from env. *(resolved, I2/I10)* -- Q-030-29–48: All resolved; see spec.md and open-questions.md. +**Q-030-01 through Q-030-53 have been resolved.** All decisions are encoded in spec.md normative sections. + +**Q-030-54 through Q-030-64 resolved** (2026-04-04): dismiss UX, maintenance blocks, uncluster, unassign, batch ops, person miniatures, face circles in detail panel, overlay config, album people, merge UI, policy refinement (deferred). + +**Q-030-65 through Q-030-73 resolved** (2026-04-04): +- Q-030-65 (A): P key confirmed free — F maps to fullscreen. Use `onKeyStroke('p', ...)`. +- Q-030-66 (A): Direct photos only (non-recursive) for album people endpoint. +- Q-030-67 (A): Selection mode toggle (not always-on checkboxes). +- Q-030-68 (A): Merge modal triggered from PersonDetail page with person search dropdown. +- Q-030-69 (A): Compact layout — 24px circle + name + face count with type-ahead filter. +- Q-030-70 (B): No touch shortcut — CTRL+click dismiss on **desktop only** (use `isTouchDevice()` guard); touch users dismiss via modal button. +- Q-030-71 (A): Horizontal scrollable row (`overflow-x: auto`) for face circles in detail panel. +- Q-030-72 (B): Policy refinement deferred (same as Q-030-63). +- Q-030-73 (A with grouping): ONE combined "Reset Face Scan Status" maintenance block for both stuck-pending AND failed (not three separate blocks, not two separate stuck/failed blocks). + +**No active questions remain for feature 030.** All 73 questions resolved. Implementation may proceed. diff --git a/docs/specs/4-architecture/knowledge-map.md b/docs/specs/4-architecture/knowledge-map.md index 827190d385d..1d206f6eac4 100644 --- a/docs/specs/4-architecture/knowledge-map.md +++ b/docs/specs/4-architecture/knowledge-map.md @@ -14,6 +14,16 @@ This document tracks modules, dependencies, and architectural relationships acro #### Domain Layer - **Models** (`app/Models/`) - Eloquent ORM models for database entities + - **Person Model** (`app/Models/Person.php`) - Represents an identified individual across multiple photos + - Optional 1-to-1 link to a `User` account (claimed person) + - `is_searchable` flag — when false, face overlays are hidden from non-owners (privacy mode) + - Has many `Face` records + - **Face Model** (`app/Models/Face.php`) - A detected face bounding box on a specific photo + - Bounding box stored as relative floats (0.0–1.0) in `x`, `y`, `width`, `height` + - `crop_token` — opaque token used to serve a cropped face thumbnail via dedicated endpoint + - `is_dismissed` — operator/owner has chosen to ignore this face detection + - Belongs to `Photo` (cascade delete), belongs to `Person` (null on delete) + - Has many `FaceSuggestion` (similar faces ranked by confidence) - **Album Model** - Nested set tree structure with pre-computed statistical fields: - `num_children` - Count of direct child albums - `num_photos` - Count of photos directly in this album (not descendants) @@ -52,6 +62,7 @@ This document tracks modules, dependencies, and architectural relationships acro - `RecomputeAlbumStatsOnAlbumChange` - Dispatches recomputation job for parent album - **Jobs** (`app/Jobs/`) - Asynchronous task definitions - `RecomputeAlbumStatsJob` - Recomputes album statistics and propagates changes to ancestors + - `ScanFacesJob` - Dispatches face detection requests to the Python AI Vision service for a batch of photo IDs; sets `face_scan_status = pending` on dispatch, `scanned` on completion - Uses `WithoutOverlapping` middleware (keyed by album_id) to prevent concurrent updates - Atomic transaction with 3 retries + exponential backoff - Propagates to parent album after successful update (cascades to root) @@ -75,13 +86,21 @@ This document tracks modules, dependencies, and architectural relationships acro - Conditional rendering (hidden when no rated photos) - Toggle behavior (click same star to clear) - Keyboard accessible (Arrow keys, Enter/Space) + - **FaceOverlay** (`photoModule/FaceOverlay.vue`) - Absolutely-positioned bounding boxes over the photo detail view; opens `FaceAssignmentModal` on click of unknown faces - Forms, modals, drawers, settings components + - **FaceAssignmentModal** - Assign an unknown face to an existing or new Person; shows suggestions ranked by confidence + - **SelfieClaimModal** - Upload a selfie to match and claim a Person profile (links Person ↔ User) + - Maintenance components + - **MaintenanceBulkScanFaces** - Card to trigger a bulk scan of all unscanned photos - **Views** (`resources/js/views/`) - Page-level Vue components - Gallery views: Albums, Album, Favourites, Flow, Frame, Map, Search - Admin views: Settings, Users, Permissions, Maintenance, Diagnostics + - People views: **People** (`/people`) — paginated PersonCard grid; **PersonDetail** (`/people/:personId`) — photos grid with edit/delete/merge actions - **Composables** (`resources/js/composables/`) - Reusable composition functions - Album, photo, search, selection, context menu composables - **Services** (`resources/js/services/`) - API communication layer using axios + - `people-service.ts` - CRUD for Person; claim/unclaim; merge; selfie upload + - `face-detection-service.ts` - Scan photos/albums; assign/dismiss faces; bulk scan - **Layouts** (`resources/js/layouts/`) - Photo layout algorithms (square, justified, masonry, grid) #### State Management @@ -106,6 +125,14 @@ This document tracks modules, dependencies, and architectural relationships acro - **LdapRecord Laravel** - LDAP/Active Directory integration (v3.4.2) - Dependency: php-ldap PHP extension required +### External Services +- **AI Vision Service** (`ai-vision-service/`) - Sidecar Python microservice for facial recognition + - Framework: FastAPI + uv package manager + - Single shared symmetric API key (`AI_VISION_FACE_API_KEY` in Lychee / `VISION_FACE_API_KEY` in Python) used in both directions via `X-API-Key` header + - File access: reads photos from a **shared Docker volume** (no file transfer over HTTP) + - Embeddings persisted to a separate named volume (`ai_vision_embeddings`) + - Supporter Edition (SE) feature: endpoints return 403 on non-SE instances + ### Frontend Dependencies - **Vue3** - Progressive JavaScript framework (Composition API) - **TypeScript** - Type-safe JavaScript @@ -231,6 +258,21 @@ Preserves original camera RAW / HEIC / PSD files as a dedicated size variant whi - Resource classes extend Spatie Data (not JsonResource) - No Blade views - Vue3 only +### AI Vision Inter-Service Communication +Lychee uses a **REST + webhook** pattern for facial recognition: + +1. **Scan trigger** — Lychee sets `face_scan_status = pending` on photo(s), dispatches `ScanFacesJob` +2. **Batch HTTP request** — Job sends `POST /detect` to Python service with an array of `{photo_id, file_path}` pairs (file paths resolve to the shared Docker volume mount) +3. **Async callback** — Python service POSTs results back to Lychee's internal callback endpoint with detected bounding boxes and embedding vectors +4. **DB write** — Lychee creates `Face` rows and `FaceSuggestion` rows; sets `face_scan_status = scanned` +5. **Cluster/compare** — On claim or merge, Lychee calls `POST /embeddings/compare` for similarity matching + +Shared volume architecture: +``` +./lychee/uploads ──► lychee_api:/app/public/uploads (read/write) + ──► ai_vision:/data/photos (read-only) +``` + ## Cross-Module Contracts ### API Communication @@ -256,6 +298,7 @@ Preserves original camera RAW / HEIC / PSD files as a dedicated size variant whi ### How-To Guides - [Add OAuth Provider](../2-how-to/add-oauth-provider.md) - Step-by-step OAuth integration - [Configure Pagination](../2-how-to/configure-pagination.md) - Album and photo pagination settings +- [Configure Facial Recognition](../2-how-to/configure-facial-recognition.md) - Docker setup, shared volume, environment variables, and permission modes - [Translating Lychee](../2-how-to/translating-lychee.md) - Translation guide for developers and translators - [Using Renamer](../2-how-to/using-renamer.md) - Filename transformation during import @@ -295,4 +338,4 @@ Preserves original camera RAW / HEIC / PSD files as a dedicated size variant whi --- -*Last updated: January 14, 2026* +*Last updated: March 22, 2026* diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index 0184d994398..b4b3c15da3b 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -6,8 +6,7 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon | Question ID | Feature | Priority | Summary | Status | Opened | Updated | |-------------|---------|----------|---------|--------|--------|---------| - -_No active questions for this feature._ +| *(no active questions for feature 030)* | | | | | | | ## Question Details @@ -256,6 +255,10 @@ _No active questions for this feature._ **Resolved:** 2026-03-18 +**Resolution: Option A adopted.** nullable INT column on . Spec updated: DO-030-02, DO-030-07, FR-030-13, FR-030-15, API-030-18/19/20. + +**Resolved:** 2026-03-23 + --- ### ~~Q-030-34: Crop Serving Route Undefined~~ ✅ RESOLVED @@ -438,6 +441,462 @@ _No active questions for this feature._ --- +### ~~Q-030-54: Dismiss Face — Button Placement and CTRL+Click Shortcut~~ ✅ RESOLVED + +**Resolution:** **Option A** — Add a "Dismiss" button in the FaceAssignmentModal. Additionally, when the user holds CTRL, face overlay rectangles switch to red dashed borders; clicking a rectangle in this state directly dismisses the face without opening the modal. Captured in FR-030-16, UI-030-08, S-030-33/34. + +**Context:** The spec provides `PATCH /Face/{id}` to toggle `is_dismissed`, but the UI only allows toggling via API. Users need a convenient visual way to dismiss false-positive faces during browsing. + +**Impact:** Affects I15 (FaceOverlay.vue), I16 (FaceAssignmentModal.vue), new UI interactions. + +**Option A (Recommended) — Dismiss button in modal + CTRL+click overlay shortcut** +- FaceAssignmentModal gets a "Dismiss" button alongside "Assign". +- FaceOverlay.vue listens for CTRL key state; when held, overlays turn red/dashed; click triggers dismiss API. +- Clear visual feedback on CTRL state change. + +**Option B — Dismiss only via modal button** +- Simpler, but requires two clicks (open modal + click dismiss) for every false positive. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-55: Maintenance Block — Destroy Dismissed Faces + Reset Stuck/Failed Scans~~ ✅ RESOLVED + +**Resolution:** **Option A** — Add a maintenance block for destroying all dismissed faces (calls `DELETE /Face/dismissed`). The block should only appear when there are dismissed faces to destroy (conditional rendering via the check endpoint). Additionally, add maintenance blocks to reset photos with face scan status "stuck" (pending too long) and "failed" so they can be re-scanned. Captured in API-030-21, API-030-22, API-030-23. + +**Context:** The `DELETE /Face/dismissed` endpoint exists (API-030-16) but has no maintenance UI block. Users need a convenient way to clean up dismissed faces. Similarly, photos stuck in "pending" or "failed" need to be resettable from the UI. + +**Impact:** Affects Maintenance.vue, new maintenance controller endpoints. + +**Option A (Recommended) — Three conditional maintenance blocks: dismiss cleanup + reset stuck + reset failed** +- `MaintenanceDestroyDismissedFaces.vue`: check returns count of dismissed faces; do calls `DELETE /Face/dismissed`; hidden when count is 0. +- `MaintenanceResetStuckFaces.vue`: (already exists) check returns count of stuck-pending; do resets them. +- `MaintenanceResetFailedFaces.vue`: check returns count of failed scans; do resets `face_scan_status` to null. + +**Option B — Single combined maintenance block** +- One block with multiple actions. Less granular but simpler UI. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-56: Uncluster Faces from a Cluster — Batch Selection + Uncluster Action~~ ✅ RESOLVED + +**Resolution:** **Option A** with batch selection — In the Cluster Review UI, users can select individual faces within a cluster (checkbox/multi-select), then choose to "uncluster" them. Unclustering sets `cluster_label = NULL` on the selected faces, removing them from the cluster without dismissing them. This allows fine-grained curation of clusters before bulk-assigning. Captured in FR-030-17, API-030-24, S-030-35. + +**Context:** DBSCAN may group unrelated faces in the same cluster. Users need to remove incorrect faces from a cluster before bulk-assigning the rest to a Person. + +**Impact:** Affects I22 (FaceClusters.vue), new API endpoint. + +**Option A (Recommended) — Select faces in cluster, then uncluster selected** +- Multi-select UI in cluster card (checkbox on each face crop). +- "Uncluster selected" button sets `cluster_label = NULL` on selected face IDs. +- New API: `POST /FaceDetection/clusters/{cluster_id}/uncluster` with body `{face_ids: []}`. + +**Option B — Drag faces out of cluster** +- Drag-and-drop UX. More intuitive but harder to implement and inaccessible. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-57: Remove Face from Person — Face Becomes Unassigned~~ ✅ RESOLVED + +**Resolution:** **Option A** — Users can remove a face from a person, which sets `face.person_id = NULL`. The face becomes unassigned (not dismissed). This is distinct from dismissing: dismiss marks the face as a false positive; unassign returns it to the pool of unassigned faces. Captured in FR-030-18, API-030-25, S-030-36. + +**Context:** After assigning faces to persons, users may discover incorrect assignments. They need to unlink a face from a person without dismissing it entirely. + +**Impact:** Affects PersonDetail.vue, FaceOverlay.vue, new/updated API endpoint. + +**Option A (Recommended) — Set `person_id = NULL` via existing assign endpoint** +- `POST /Face/{id}/assign` with `person_id: null` (or a dedicated `unassign` action). +- The face returns to the unassigned pool and may appear in future cluster runs. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-58: Batch Face Operations — Select Multiple Faces, Unassign/Assign/Create Person~~ ✅ RESOLVED + +**Resolution:** **Option A** — For a person or cluster view, users can select a set of faces (multi-select with checkboxes), then choose from: (a) unassign all selected (set `person_id = NULL`), (b) assign all selected to another existing person, (c) assign all selected to a new person. This applies in both Person Detail and Cluster Review contexts. Captured in FR-030-19, API-030-26, S-030-37. + +**Context:** One-by-one face operations are tedious for large datasets. Batch operations dramatically improve UX for face curation. + +**Impact:** Affects PersonDetail.vue, FaceClusters.vue, batch API endpoint. + +**Option A (Recommended) — Batch action bar with select mode** +- Toggle "select mode" in person/cluster views. +- Checkbox overlay on each face crop. +- Action bar appears: "Unassign (N)", "Reassign to...", "Assign to new person". +- `POST /Face/batch` with `{face_ids: [], action: "unassign"|"assign", person_id?: string, new_person_name?: string}`. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-59: Person Miniature in Face Assignment Dropdown~~ ✅ RESOLVED + +**Resolution:** **Option A** — When listing persons in the face assignment modal dropdown, each entry shows a small circular face crop miniature (the representative crop) next to the person name. This helps differentiate people with the same name. Captured in FR-030-20, UI-030-09. + +**Context:** Multiple persons can share the same name (e.g., two people named "John"). Without a visual differentiator, users cannot distinguish them in the dropdown. + +**Impact:** Affects I16 (FaceAssignmentModal.vue), PersonResource (already includes `representative_crop_url`). + +**Option A (Recommended) — Circular miniature + name in dropdown** +- PrimeVue Dropdown with custom `option` template slot. +- Each option: 24px circular `` (representative_crop_url) + person name + face count. +- Fallback placeholder icon when no representative crop exists. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-60: Face Circles in Photo Detail Panel~~ ✅ RESOLVED + +**Resolution:** **Option A** — When the photo details panel (sidebar) is open and the photo has detected faces, display them as circular face crop thumbnails with the person name underneath. Clicking a face circle opens the FaceAssignmentModal. CTRL+clicking a face circle dismisses it (same pattern as CTRL+click on overlay). Captured in FR-030-21, UI-030-10, S-030-38/39. + +**Context:** Face overlays on the main photo image may be hard to interact with (small faces, precise clicking). The detail panel provides a more accessible interface for face management. + +**Impact:** Affects PhotoDetails.vue, new sub-component, FaceAssignmentModal integration. + +**Option A (Recommended) — Circular crops in detail panel with click/CTRL+click** +- New section "People in this photo" in PhotoDetails.vue. +- Row of circular face crops (48px diameter) with name label below. +- Click → open FaceAssignmentModal for that face. +- CTRL+click → dismiss face directly. +- "Unknown" label for unassigned faces. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-61: Face Overlay Global Config Settings~~ ✅ RESOLVED + +**Resolution:** **Option A** with modification — Two **global** config settings (both in `configs` table, not per-user): (1) `ai_vision_face_overlay_enabled` (0|1, default 1): master toggle that enables/disables the face overlay feature entirely. When 0, no face overlays are rendered anywhere. (2) `ai_vision_face_overlay_default_visibility` (enum: `visible`|`hidden`, default `visible`): sets whether face overlays are shown or hidden by default when viewing a photo. Users can toggle visibility with the `P` key. Per-user configuration deferred to a future enhancement. Captured in NFR-030-11, config table entries. + +**Context:** Some users may find face overlays distracting. A global toggle and default visibility setting provide admin control over the face overlay UX. + +**Impact:** Affects config migration, FaceOverlay.vue, PhotoDetails.vue, keybinding. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-62: Album People Endpoint~~ ✅ RESOLVED + +**Resolution:** **Option A** with modification — New endpoint `GET /api/v2/Album/{id}/people` returns the list of people found in a given album. The response uses the same `PaginatedPersonsResource` pattern as the People listing (consistent with `CollectionPhotoResource` style responses, not `ResourceName::collect()`). Photos are linked to albums via the `photo_albums` pivot table (not a direct `album_id` on photos). The query joins `photo_albums → photos → faces → persons` to collect distinct persons. Captured in FR-030-22, API-030-27, S-030-40. + +**Context:** When browsing an album, users want to see which people appear in it. This enables a "People in this album" section in the album detail view. + +**Impact:** New API endpoint, possible album detail UI enhancement. + +**Option A (Recommended) — Distinct persons via photo_albums join** +- `SELECT DISTINCT persons.* FROM persons JOIN faces ON faces.person_id = persons.id JOIN photos ON faces.photo_id = photos.id JOIN photo_albums ON photo_albums.photo_id = photos.id WHERE photo_albums.album_id = ?` +- Returns `PaginatedPersonsResource`. +- Respects `ai_vision_face_permission_mode` visibility and `is_searchable` filtering. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-63: Policy Refinement — Album/Photo Rights vs Face-Level Policy~~ ✅ RESOLVED + +**Resolution:** Deferred for now. The current four-level permission mode semantic is correct. However, the policy also needs refinement with regard to album/photo edit rights (which is not currently applied). For now, focus on the UI/UX interaction. Policy refinement will be revisited in a future iteration. Captured as a note in NFR-030-07. + +**Context:** The `AiVisionPolicy` currently checks `ai_vision_face_permission_mode` globally but does not cross-check whether the user has edit rights on the specific album or photo. For example, in `privacy-preserving` mode, "photo/album owner + admin" should mean the owner of the specific album/photo, but the current policy may not check actual album ownership. + +**Impact:** Deferred — no immediate spec changes beyond noting the gap. + +**Resolved:** 2026-04-04 (deferred for future iteration) + +--- + +### ~~Q-030-65: Face Overlay Toggle Key Binding Conflict — Does P Already Have a Mapping?~~ ✅ RESOLVED + +**Resolution:** **Option A** — Use `P`. Confirmed that `P` has no existing binding. `F` is mapped to fullscreen (`togglableStore.toggleFullScreen()` in `Album.vue`). `P` is free and is used for toggling face overlay visibility. Captured in NFR-030-11, FR-030-21, I24. + +**Context:** FR-030-21 specifies mapping the `P` key to toggle face overlay visibility. The existing Lychee photo viewer may already use `P` for another action (e.g., play slideshow, or some other shortcut). If there is a conflict, we need to choose a different key. + +**Impact:** If `P` conflicts with an existing binding, the implementation would override existing behaviour. Affects I23 (keybinding setup), FaceOverlay.vue. + +**Option A (Recommended) — Use `P` if available; otherwise use `F` or another unbound key** +- Check existing key bindings in the photo viewer. If `P` is free, use it. If not, fall back to `F` (for "Faces"). + +**Option B — Always use `F` for "Faces" regardless** +- Avoids any conflict risk, but `P` is more intuitive for "People". + +**Affects:** FR-030-21, FaceOverlay.vue, keybinding system. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-66: Album People Endpoint — Recursive vs Direct Photos Only~~ ✅ RESOLVED + +**Resolution:** **Option A** — Direct photos only (non-recursive). Consistent with existing bulk scan behaviour (Q-030-41). Sub-album people can be viewed by navigating to each sub-album. Captured in FR-030-22, API-030-25 (renamed from API-030-27). + +**Context:** FR-030-22 adds `GET /Album/{id}/people`. Should it include people from sub-album photos (recursive) or only direct photos in the album (joined via `photo_albums` where `album_id = ?`)? + +**Impact:** Recursive requires either a CTE or pre-computing the album tree. Direct is simpler and consistent with how bulk scan works (non-recursive per Q-030-41). Affects API-030-25. + +**Option A (Recommended) — Direct photos only (non-recursive)** +- Consistent with bulk scan behaviour. Sub-album people can be viewed by navigating to each sub-album. +- Query is simpler and faster. + +**Option B — Recursive through sub-albums** +- More comprehensive but potentially expensive for deep album trees. May require album path pre-computation. + +**Affects:** FR-030-22, API-030-25, AlbumPeopleController. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-67: Batch Face Selection UX — Checkbox Overlay or Selection Mode Toggle?~~ ✅ RESOLVED + +**Resolution:** **Option A** — Selection mode toggle. A "Select" button toggles selection mode; checkboxes appear on face crops only when active. Action bar slides in at the bottom. Captured in FR-030-19, UI-030-12. + +**Context:** FR-030-19 specifies batch face selection in person/cluster views. Should selection be always-on (checkboxes always visible) or require entering a "select mode" first (like file managers)? + +**Impact:** Affects the visual density and usability of the face grid in PersonDetail.vue and FaceClusters.vue. + +**Option A (Recommended) — Selection mode toggle** +- A "Select" button toggles selection mode. When active, checkbox overlays appear on each face crop. Action bar slides in at the bottom. +- Cleaner default view; explicit mode transition. + +**Option B — Always-visible checkboxes** +- No mode switch needed; faster for power users. But clutters the UI. + +**Affects:** FR-030-19, PersonDetail.vue, FaceClusters.vue. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-68: Person Merge UI — Location and Target Selection~~ ✅ RESOLVED + +**Resolution:** **Option A** — "Merge into..." button on PersonDetail page, opens modal with person search dropdown. Captured in FR-030-25, UI-030-13, MergePersonModal.vue. + +**Context:** FR-030-11 allows merging two Person records. The backend supports `POST /Person/{id}/merge` with `source_person_id` in body. Where should the merge UI live? How should the user select the target person? + +**Impact:** Affects PersonDetail.vue, possible new MergePersonModal. + +**Option A (Recommended) — "Merge into..." button on PersonDetail page, opens modal with person search dropdown** +- PersonDetail page has a "Merge" button. Clicking opens a modal with a person search dropdown (same component as assignment modal). User selects target person; confirms merge. + +**Option B — Drag-and-drop between person cards on People page** +- More visual but hard to discover and inaccessible. + +**Affects:** PersonDetail.vue, new MergePersonModal.vue. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-69: Person Miniature Size and Layout for Same-Name Persons~~ ✅ RESOLVED + +**Resolution:** **Option A** — Compact layout: 24px circle + name + face count, with type-ahead filter already built into PrimeVue Select/Dropdown. Captured in FR-030-20, UI-030-09. + +**Context:** FR-030-20 adds circular miniatures in the face assignment dropdown. If there are many persons with the same name, the dropdown may become long and hard to navigate. + +**Impact:** Minor UX concern. Affects FaceAssignmentModal.vue dropdown template. + +**Option A (Recommended) — Compact layout: 24px circle + name + face count, with type-ahead filter** +- The PrimeVue Dropdown already supports filtering. The miniature helps differentiate same-name entries visually. No special layout needed beyond the custom option template. + +**Option B — Group same-name persons with sub-labels (e.g., "John (142 photos)" vs "John (3 photos)")** +- Adds face count as disambiguator alongside the miniature. + +**Affects:** FR-030-20, FaceAssignmentModal.vue. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-70: CTRL+Click Dismiss on Touch Devices~~ ✅ RESOLVED + +**Resolution:** **Option B** — No touch shortcut. Dismiss only via the modal button. On touch devices (detected via `isTouchDevice()` from `keybindings-utils.ts`), the CTRL+click behaviour is not implemented. Touch users open the modal and click the "Dismiss" button. Captured in FR-030-16 (updated), UI-030-08 (desktop-only note). + +**Context:** FR-030-16 uses CTRL+click as a shortcut for face dismissal on overlays and in the detail panel. Touch devices (tablets, phones) don't have a CTRL key. + +**Impact:** Touch users would have no shortcut for face dismissal and must use the modal button instead. + +**Option A — Long-press on touch devices triggers dismiss** +- Long-press (500ms+) on a face overlay or face circle opens a context menu with "Dismiss" option. +- Alternatively, long-press directly dismisses (with undo toast). + +**Option B (Chosen) — No touch shortcut; dismiss only via modal** +- Simplest approach. Touch users open the modal and click the dismiss button. + +**Affects:** FR-030-16, FaceOverlay.vue, PhotoDetails.vue face circles. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-71: Face Circles in Photo Detail Panel — Layout When Panel Is Narrow~~ ✅ RESOLVED + +**Resolution:** **Option A** — Horizontal scrollable row with overflow indicator. Flex row with `overflow-x: auto`. When faces exceed visible width, a "+N more" badge is shown; the row is scrollable to reveal all faces. Captured in FR-030-21, UI-030-10. + +**Context:** FR-030-21 adds circular face crops to the PhotoDetails sidebar. The sidebar is fixed at `w-95` (380px). If a photo has many faces (10+), the circles may overflow. + +**Impact:** Layout overflow or truncation for photos with many detected faces. + +**Option A (Recommended) — Horizontal scrollable row with overflow indicator** +- Flex row with `overflow-x: auto`. Shows "+N more" indicator when faces overflow. +- Clicking "+N more" expands to a grid view. + +**Option B — Wrapping grid layout** +- Faces wrap to multiple rows. May push other detail sections down significantly. + +**Affects:** FR-030-21, PhotoDetails.vue face section. + +**Resolved:** 2026-04-04 + +--- + +### ~~Q-030-72: Policy Refinement — Album/Photo Edit Rights~~ ✅ RESOLVED + +**Resolution:** **Option B** — Defer to next iteration. The current four-level permission mode semantic (public/private/privacy-preserving/restricted) provides a reasonable baseline. Policy refinement for per-resource album/photo ownership is deferred. This is the same conclusion as Q-030-63. Captured as a note in NFR-030-07 policy refinement note. + +**Context:** The current `AiVisionPolicy` checks the global `ai_vision_face_permission_mode` but does not cross-reference the user's actual edit rights on the specific album or photo. In `privacy-preserving` and `restricted` modes, "photo/album owner" should mean the owner of that specific resource, but the current implementation may check ownership globally. + +**Impact:** High — could allow users to assign/dismiss faces on photos they don't own. Affects all face operations gated on "photo/album owner + admin". + +**Option A — Add album/photo ownership checks to policy methods** +- For operations like face assignment, dismiss, and scan trigger: check that the authenticated user owns (or has edit rights on) the photo or its containing album. +- Use existing `PhotoPolicy` and `AlbumPolicy` gates alongside `AiVisionPolicy`. + +**Option B (Chosen) — Defer to next iteration (current approach)** +- Accept the gap for now. The four-level mode provides a reasonable baseline. Refine later. + +**Affects:** AiVisionPolicy, FaceController, FaceDetectionController, all face-related request classes. + +**Resolved:** 2026-04-04 (deferred for future iteration) + +--- + +### ~~Q-030-73: Reset Face Scan Status Maintenance Blocks — Separate or Combined?~~ ✅ RESOLVED + +**Resolution:** **Option A with grouping** — Group stuck-pending and failed resets into a **single** combined maintenance block, distinct from the "Destroy Dismissed Faces" block. The final UI has exactly two face maintenance action blocks: (1) "Destroy Dismissed Faces" and (2) "Reset Face Scan Status" (handles both stuck-pending and failed). The existing `Maintenance::resetStuckFaces` backend endpoint remains available for CLI use but no longer has a dedicated UI card. Captured in FR-030-24 (updated), API-030-22/22b (renamed to `resetFaceScanStatus`), UI-030-15. + +**Context:** Q-030-55 resolution requires maintenance blocks for: (a) destroying dismissed faces, (b) resetting stuck-pending scans, (c) resetting failed scans. Should these be three separate maintenance cards or combined into fewer? + +**Impact:** Affects Maintenance.vue layout and number of maintenance controllers. + +**Option A with grouping (Chosen) — Two conditional blocks: dismiss cleanup + combined reset stuck/failed** +- Block 1: `MaintenanceDestroyDismissedFaces.vue` — destroys dismissed faces (count > 0 to show). +- Block 2: `MaintenanceResetFaceScanStatus.vue` — combined reset of stuck-pending (>720 min) AND failed scans. + - check: `count_stuck + count_failed`; hidden when 0 + - do: resets both `PENDING` (older than 720 min) and `FAILED` photos to `null` + +**Option B — Three separate conditional blocks** +- Each block independently checks its count and hides when zero. Clear, granular control. + +**Affects:** FR-030-24, Maintenance.vue, API-030-22/22b, new `ResetFaceScanStatus.php` controller. + +**Resolved:** 2026-04-04 + +### ~~Q-030-50: `PersonResource.representative_crop_url` — Selection Rule Unspecified~~ ✅ RESOLVED + +**Resolution:** **Options A + C** combined. Default logic uses highest-confidence non-dismissed face (`ORDER BY confidence DESC LIMIT 1`). A `representative_face_id` nullable FK→`faces` ON DELETE SET NULL is also added to the `persons` table (DO-030-08, T-030-53), allowing admins/users to override the representative via `PATCH /Person/{id}`. `PersonResource` uses the FK if set (and the referenced Face has a `crop_token`); otherwise falls back to the highest-confidence SELECT. Captured in DO-030-01, DO-030-03, DO-030-08, T-030-10 (note), T-030-18, T-030-53. + +**Context:** DO-030-03 (`PersonResource`) lists `representative_crop_url` as a field. T-030-18 mentions it. The spec has no rule for *which* Face crop is chosen as representative. `PersonCard.vue` uses it as the person's avatar on the People page. + +**Impact:** If implementors pick different strategies independently, the result will differ from what product design expects. Affects I6 (FaceResource), I13 (People page thumbnails). + +**Option A (Recommended) — highest-confidence face crop** +- `SELECT crop_token FROM faces WHERE person_id = ? AND is_dismissed = false AND crop_token IS NOT NULL ORDER BY confidence DESC LIMIT 1` +- Deterministic, stable once detection quality is good, no additional sort column needed. + +**Option B — most-recently added face crop** +- `ORDER BY created_at DESC LIMIT 1` +- Reflects the latest photo that person appeared in — may be more "current" but less relevant. + +**Option C — null until user explicitly sets a representative face** +- Add a `representative_face_id` nullable FK on `persons` table, set via a new PATCH sub-action. +- Fully explicit but requires extra migration and UI affordance. + +**Affects:** DO-030-03, T-030-18, PersonResource, PersonCard.vue. + +--- + +### ~~Q-030-51: `ai_vision_enabled` / `ai_vision_face_enabled` Gating Hierarchy~~ ✅ RESOLVED + +**Resolution:** **Option A** — compound gate. All `ai_vision_face_*` functionality is implicitly gated on `ai_vision_enabled = 1`. Any code path that gates on `ai_vision_face_enabled` must first confirm `ai_vision_enabled = 1`. If `ai_vision_enabled = 0`, all AI Vision endpoints return 503 / UI hides all AI Vision elements, regardless of `ai_vision_face_enabled`. Captured in NFR-030-10 and config table note for `ai_vision_face_enabled`. + +**Context:** T-030-12 adds two separate config flags: `ai_vision_enabled` (global feature kill-switch for the whole AI Vision system) and `ai_vision_face_enabled` (specifically enables face detection). T-030-29 fires auto-scan when `ai_vision_face_enabled = 1`, but no task or spec explicitly states that `ai_vision_face_enabled` must ALSO check `ai_vision_enabled` first. An implementor could check only one flag or check both. + +**Impact:** If `ai_vision_enabled = 0` but `ai_vision_face_enabled = 1`, undefined behaviour. Affects I8, I9, I10, I17 (every place that gates on the config). + +**Option A (Recommended) — `ai_vision_face_enabled` implies `ai_vision_enabled`; guard with both** +- Any code path that checks `ai_vision_face_enabled` must first confirm `ai_vision_enabled`. Documented as a compound gate in NFR or as a middleware. +- Spec adds: "All `ai_vision_face_*` functionality is implicitly gated on `ai_vision_enabled = 1`." + +**Option B — single flag; remove `ai_vision_enabled`** +- Since only face detection exists now, `ai_vision_face_enabled` is the only effective toggle. `ai_vision_enabled` is removed or deferred to when a second AI Vision feature ships. +- Simpler, but loses the global kill-switch if other AI features follow. + +**Option C — independent flags; document the combination table** +- `ai_vision_enabled` controls API availability (503 when off). `ai_vision_face_enabled` controls auto-on-upload and People page visibility. Both can vary independently. + +**Affects:** FR-030-08, NFR-030-03, T-030-12, T-030-29, T-030-38, FaceDetectionController, FaceDetectionService.ts. + +--- + +### ~~Q-030-52: Embedding Deletion Dispatch Hook — Observer vs. Photo Pipeline~~ ✅ RESOLVED + +**Resolution:** **Option B** — no Face model observer. Two explicit call-sites: (1) `destroyDismissed` action — collect dismissed face IDs before `Face::where('is_dismissed', true)->delete()`, dispatch `DeleteFaceEmbeddingsJob`; (2) `PhotoObserver::deleting` — collect `$photo->faces()->pluck('id')` before cascade, dispatch batch job. Captured in FR-030-14 and T-030-49. + +**Context:** T-030-49 (FR-030-14) specifies dispatching `DeleteFaceEmbeddingsJob` when Face records are hard-deleted. The task says "Face model observer `deleting` event, **or** by hooking into the Photo delete pipeline." These are architecturally different: + +- **Observer (`deleting`)**: fires per-row; requires N individual event firings for a batch delete; works for both cascade-from-Photo and admin bulk-delete paths uniformly. +- **Photo pipeline hook**: collects all `face_ids` before the cascade delete, dispatches one batch job; avoids N observer firings but only covers Photo→Face cascade. The admin `destroyDismissed` path still needs its own dispatch. + +**Impact:** The observer approach fires for every delete path automatically but causes N jobs for a batch Photo cascade. The pipeline approach is more efficient but duplicates dispatch logic. Affects I21 (PHP `DeleteFaceEmbeddingsJob`), T-030-49. + +**Option A (Recommended) — Observer on `deleting`, but coalesce with batch dispatch** +- Register a `Face` model observer. On `deleting`, collect IDs into a static `$pendingDeletion` buffer. A `deleted` static hook (or `booted` teardown) dispatches one job for the full batch at the end of the request lifecycle. Handles all delete paths without duplicating logic. + +**Option B — No observer; explicit dispatch at each call-site** +- `destroyDismissed`: collects IDs before delete, dispatches one job explicitly. +- Photo delete: `PhotoObserver::deleting` collects `$photo->faces()->pluck('id')` before cascade, dispatches job. +- Simpler per-path but requires remembering to add dispatch at every future Face-delete call-site. + +**Affects:** FR-030-14, T-030-49, I21, Face model observer, PhotoObserver, `destroyDismissed` action. + +--- + +### ~~Q-030-49: Cluster Storage Model — Resolved~~ ✅ RESOLVED — How Should the Backend Know About Existing Clusters? + +**Context:** FR-030-15 and API-030-18/19/20 specify a Cluster Review page. The current spec says `cluster_id` is "derived from the suggestion graph" (connected components of `face_suggestions`). This approach has three fatal flaws: (1) O(V+E) graph traversal per `GET /clusters` request violates NFR-030-02; (2) SHA1-of-sorted-face-IDs IDs are unstable — they change when any face in the cluster is dismissed or assigned, breaking `POST .../clusters/{id}/assign`; (3) pagination over connected components requires materialising all clusters first. + +**Option A (Recommended) — `cluster_label` nullable INT column on `faces`** +- DBSCAN already produces integer labels (0, 1, 2... for clusters; -1 = noise). Persist them directly. +- `POST /cluster-results` payload carries `{face_id, cluster_label}[]` alongside suggestion pairs; PHP bulk-updates `faces.cluster_label`. +- `GET /clusters` = standard `GROUP BY cluster_label` SQL with `LIMIT/OFFSET`; composite index on `(cluster_label, person_id, is_dismissed)`. +- `cluster_id` in the API = `cluster_label` integer (stable between clustering runs). +- Assign/dismiss = `WHERE cluster_label = ?`. +- Stale for faces added after last clustering run (they have `cluster_label = NULL` and don't appear until re-cluster). Acceptable — Cluster Review is explicitly post-clustering UX. +- **One nullable column. No new model. Trivial pagination.** + +**Option B — Separate `face_clusters` table + FK on `faces`** +- `face_clusters`: id (ULID), run_at, size (cached). +- `faces.cluster_id` FK → `face_clusters.id`. +- Pros: opaque ULID IDs, can record run timestamp. Cons: extra table + model, more complex ingestion, size cache goes stale on dismiss/assign. Not worth the complexity over A. + +**Option C — Keep as-is (on-the-fly BFS/DFS over `face_suggestions`)** +- Always reflects current suggestion relationships (no staleness). +- Fatal: O(V+E) per page load, unstable IDs, pagination infeasible. Violates NFR-030-02. **Not viable at scale.** + +**Required spec changes if Option A adopted:** +- `DO-030-02`: add `cluster_label` (nullable INT) to Face +- `DO-030-06` / migration: add `cluster_label INT NULL` column + composite index `(cluster_label, person_id, is_dismissed)` on `faces` +- `FR-030-13` (`POST /cluster-results`): body gains `{face_id: str, cluster_label: int | null}[]` alongside suggestion pairs +- `FR-030-15` / API-030-18/19/20: `cluster_id` = `cluster_label` integer; remove "opaque stable identifier derived from the suggestion graph" language; add note that noisy faces (`cluster_label = NULL`) excluded from Cluster Review + + ### ~~Q-030-32: InsightFace Model Acquisition — Baked Into Docker Image vs. Runtime Download~~ ✅ RESOLVED **Resolution:** **Option A** — bake `buffalo_l` model weights into the Docker image at build time via a `RUN` step in the builder stage. The multi-stage Dockerfile copies the downloaded model folder from builder to runtime. Image is significantly larger (~1GB+) but starts instantly and works in airgapped environments. Model updates require an image rebuild (acceptable given model stability). diff --git a/docs/specs/4-architecture/roadmap.md b/docs/specs/4-architecture/roadmap.md index 529ea1a6a41..9d49a7023de 100644 --- a/docs/specs/4-architecture/roadmap.md +++ b/docs/specs/4-architecture/roadmap.md @@ -6,9 +6,7 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Status | Priority | Assignee | Started | Updated | Progress | |------------|------|--------|----------|----------|---------|---------|----------| -| 032 | Security Advisories Check | Planning | P0 | LycheeOrg | 2026-04-06 | 2026-04-06 | Spec, plan, tasks drafted. 18 tasks across 6 increments (I1 config/DTO, I2 fetch service, I3 diagnostic pipe, I4 REST endpoint, I5 frontend modal, I6 quality gates). All open questions resolved in spec. Ready to begin T-032-01. | | 030 | AI Vision Service | Planning | P1 | LycheeOrg | 2026-03-15 | 2026-03-15 | Spec, plan, tasks drafted. 43 tasks across 19 increments (I1–I3 Python service, I4–I12 PHP backend, I13–I18 frontend, I19 docs). Q-030-01 through Q-030-12 resolved. 13 new open questions (Q-030-13 through Q-030-25) — 6 high, 7 medium. I1–I3 can start; I8 blocked on Q-030-13; I10 blocked on Q-030-14, Q-030-15, Q-030-17. | -| 026 | Album Photo Tag Filter | Ready for Implementation | P2 | LycheeOrg | 2026-03-09 | 2026-03-09 | Spec, plan, tasks complete. 76 tasks across 9 increments (~32h estimated). All open questions resolved. Ready to begin Task 1.1. | ## Paused Features @@ -20,8 +18,10 @@ High-level planning document for Lychee features and architectural initiatives. | Feature ID | Name | Completed | Notes | |------------|------|-----------|-------| +| 032 | Security Advisories Check | Planning | P0 | LycheeOrg | 2026-04-06 | 2026-04-06 | Spec, plan, tasks drafted. 18 tasks across 6 increments (I1 config/DTO, I2 fetch service, I3 diagnostic pipe, I4 REST endpoint, I5 frontend modal, I6 quality gates). All open questions resolved in spec. Ready to begin T-032-01. | | 029 | Camera Capture | 2026-03-18 | "Take Photo" in `+` add menu (album and root views). CameraCapture.vue modal: live video → canvas capture → JPEG preview → push to existing UploadPanel queue. Secure-context guard, mobile layout fixes, `Permissions-Policy: camera=(self)` header. No backend changes. | | 028 | Search UI Refactor | 2026-05-30 | Full refactor: simple input + collapsible advanced panel (17 fields: title, description, location, tags, date range, type, orientation, rating, EXIF fields). Token assembler/parser composable. No-debounce on-demand search. Auto-scroll to first result. vue-tsc clean, 74 PHP tests passed, PHPStan 0 errors. | +| 026 | Album Photo Tag Filter | Ready for Implementation | P2 | LycheeOrg | 2026-03-09 | 2026-03-09 | Spec, plan, tasks complete. 76 tasks across 9 increments (~32h estimated). All open questions resolved. Ready to begin Task 1.1. | | 025 | Dynamic Landing Background Options | 2026-03-03 | Spec, plan, tasks completed | | 024 | CLI Sync File-List Support | 2026-03-03 | `lychee:sync` now accepts individual file paths alongside directories; `Exec::doFiles()` added; `ImportImageJob` accepts nullable Album; 7 new tests. | | 023 | Remember Me Login | 2026-03-01 | Spec, plan, tasks drafted. Implementation completed. | diff --git a/lang/en/gallery.php b/lang/en/gallery.php index 31fccb5e091..b2d0b2a893f 100644 --- a/lang/en/gallery.php +++ b/lang/en/gallery.php @@ -315,6 +315,8 @@ 'merge_all' => 'Merge Selected', 'apply_renamer' => 'Apply Renamer', 'apply_renamer_all' => 'Apply Renamer to Selected', + 'scan_faces' => 'Scan for Faces', + 'scan_faces_all' => 'Scan Selected for Faces', 'upload_photo' => 'Upload Photo', 'take_photo' => 'Take Photo', 'import_link' => 'Import from Link', diff --git a/lang/en/left-menu.php b/lang/en/left-menu.php index dc78742aa75..aab7be424c6 100644 --- a/lang/en/left-menu.php +++ b/lang/en/left-menu.php @@ -24,5 +24,6 @@ 'support' => 'Support', 'contact' => 'Contact', 'messages' => 'Messages', + 'people' => 'People', 'webhooks' => 'Webhooks', ]; diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 9774cccf29e..22e93258fcd 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -99,4 +99,29 @@ 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:recompute-album-sizes', 'button' => 'Compute sizes', ], + 'bulk-scan-faces' => [ + 'description' => 'Found %d photos that have not yet been scanned for facial recognition.

Requires the AI Vision service to be running.', + ], + 'run-clustering' => [ + 'description' => 'Trigger face clustering in the AI Vision service. Groups detected faces by similarity so you can assign them to people.', + 'success' => 'Clustering started successfully.', + ], + 'destroy-dismissed-faces' => [ + 'title' => 'Destroy Dismissed Faces', + 'description' => 'Found %d dismissed faces. Destroying them will permanently delete their crop files and embeddings.', + 'action' => 'Destroy All', + 'success' => 'Dismissed faces destroyed successfully.', + ], + 'sync-face-embeddings' => [ + 'title' => 'Sync Face Embeddings', + 'description' => 'Face count mismatch detected (%d difference). Syncing will pull latest face data from AI Vision service to Lychee.', + 'action' => 'Sync Now', + 'success' => 'Face embeddings synchronized successfully.', + ], + 'reset-face-scan-status' => [ + 'title' => 'Reset Face Scan Status', + 'description' => 'Found %d photos with a stuck-pending or failed face scan status. Resetting them will allow them to be re-scanned.', + 'action' => 'Reset All', + 'success' => 'Face scan statuses reset successfully.', + ], ]; diff --git a/lang/en/people.php b/lang/en/people.php new file mode 100644 index 00000000000..dab89444c3a --- /dev/null +++ b/lang/en/people.php @@ -0,0 +1,77 @@ + 'People', + 'description' => 'Browse photos by the people in them.', + 'no_people' => 'No people found yet. Scan some photos to detect faces.', + 'photos_label' => 'photo(s)', + 'faces_label' => 'face(s)', + 'hidden_faces' => 'face(s) hidden for privacy', + 'unknown' => 'Unknown', + 'confidence' => 'Confidence', + 'laplacian_variance' => 'Laplacian Variance', + 'assign_face' => 'Assign face', + 'dismiss_face' => 'Dismiss face', + 'undismiss_face' => 'Undismiss face', + 'scan_faces' => 'Scan for faces', + 'scanning' => 'Scanning for faces…', + 'scan_success' => 'Face scan queued successfully.', + 'not_searchable' => 'Hidden', + 'searchable' => 'Visible', + 'claim_by_selfie' => 'Find me in photos', + 'claim_by_selfie_description' => 'Upload a selfie to find and link your person profile.', + 'claims' => [ + 'success' => 'Successfully linked to your profile.', + 'no_face' => 'No face detected in the selfie.', + 'no_match' => 'No matching person found.', + 'already_claimed' => 'This person is already linked to another user.', + 'low_confidence' => 'Match confidence too low. Please try a clearer photo.', + ], + 'person' => [ + 'edit' => 'Edit', + 'delete' => 'Delete', + 'merge' => 'Merge into…', + 'toggle_searchable' => 'Toggle visibility', + 'claim' => 'This is me', + 'unclaim' => 'Unlink from me', + 'photos_title' => 'Photos of %s', + ], + 'clusters_title' => 'Face Clusters', + 'run_clustering' => 'Run Clustering', + 'no_clusters' => 'No clusters found. Run clustering to group detected faces.', + 'faces' => 'faces', + 'enter_name' => 'Person name…', + 'assign' => 'Assign', + 'dismiss' => 'Dismiss', + 'assignment' => [ + 'title' => 'Assign face to person', + 'select_person' => 'Select existing person…', + 'new_person' => 'Or create new person', + 'new_person_placeholder' => 'New person name…', + 'confirm' => 'Assign', + 'cancel' => 'Cancel', + 'success' => 'Face assigned successfully.', + 'dismiss' => 'Dismiss', + 'dismissed' => 'Face dismissed successfully.', + ], + 'people_in_photo' => 'People in this photo', + 'merge' => [ + 'title' => 'Merge person', + 'into' => 'into…', + 'select_target' => 'Select target person…', + 'warning' => 'This will move all faces from the source person to the target person and delete the source. This cannot be undone.', + 'confirm' => 'Merge', + 'success' => 'Persons merged successfully.', + ], +]; diff --git a/resources/js/components/drawers/PhotoDetails.vue b/resources/js/components/drawers/PhotoDetails.vue index 9231adac2e8..2743234ebb2 100644 --- a/resources/js/components/drawers/PhotoDetails.vue +++ b/resources/js/components/drawers/PhotoDetails.vue @@ -221,6 +221,53 @@ :key="`rating-${photoStore.photo.id}`" /> + + + @@ -237,15 +284,25 @@ import ColourSquare from "@/components/gallery/photoModule/ColourSquare.vue"; import PhotoRatingWidget from "@/components/gallery/photoModule/PhotoRatingWidget.vue"; import { useLycheeStateStore } from "@/stores/LycheeState"; import LinksInclude from "@/components/gallery/photoModule/LinksInclude.vue"; +import FaceAssignmentModal from "@/components/modals/FaceAssignmentModal.vue"; import { storeToRefs } from "pinia"; import { usePhotoStore } from "@/stores/PhotoState"; import { useRouter } from "vue-router"; import PhotoService from "@/services/photo-service"; import { useTogglablesStateStore } from "@/stores/ModalsState"; +import { useLeftMenuStateStore } from "@/stores/LeftMenuState"; +import FaceDetectionService from "@/services/face-detection-service"; +import { useToast } from "primevue/usetoast"; +import { trans } from "laravel-vue-i18n"; +import { isTouchDevice } from "@/utils/keybindings-utils"; const photoStore = usePhotoStore(); const router = useRouter(); const togglableStore = useTogglablesStateStore(); +const leftMenuStore = useLeftMenuStateStore(); +const { initData } = storeToRefs(leftMenuStore); +const toast = useToast(); +const isTouchDev = isTouchDevice(); const props = defineProps<{ isMapVisible: boolean; @@ -298,4 +355,59 @@ watch( }, { immediate: true }, ); + +// Face circles section +const faceForAssignment = ref(undefined); +const isFaceAssignmentOpen = ref(false); + +function openFaceAssignment(face: App.Http.Resources.Models.FaceResource) { + faceForAssignment.value = face; + isFaceAssignmentOpen.value = true; +} + +function removeFaceFromPhoto(faceId: string) { + if (photoStore.photo?.faces) { + const faceIndex = photoStore.photo.faces.findIndex((f) => f.id === faceId); + if (faceIndex !== -1) { + photoStore.photo.faces.splice(faceIndex, 1); + } + } +} + +function dismissFace(face: App.Http.Resources.Models.FaceResource) { + if (isTouchDev) { + // Touch devices: do not dismiss via shortcut — open modal instead + openFaceAssignment(face); + return; + } + + // Immediately remove from store for instant feedback + removeFaceFromPhoto(face.id); + + FaceDetectionService.toggleDismissed(face.id) + .then(() => { + toast.add({ severity: "success", summary: trans("toasts.success"), detail: trans("people.assignment.dismissed"), life: 3000 }); + onFaceUpdated(); + }) + .catch((e: { response?: { data?: { message?: string } } }) => { + // On error, reload to restore face + photoStore.load(); + toast.add({ severity: "error", summary: trans("toasts.error"), detail: e.response?.data?.message, life: 3000 }); + }); +} + +function onFaceUpdated() { + // Remove dismissed face from store immediately if modal was used + if (faceForAssignment.value) { + removeFaceFromPhoto(faceForAssignment.value.id); + } + + // Force reload photo to refresh face data + if (photoStore.photo?.id) { + const currentId = photoStore.photoId; + photoStore.$patch({ photo: undefined }); + photoStore.photoId = currentId; + photoStore.load(); + } +} diff --git a/resources/js/components/gallery/PersonCard.vue b/resources/js/components/gallery/PersonCard.vue new file mode 100644 index 00000000000..0ea39a6845f --- /dev/null +++ b/resources/js/components/gallery/PersonCard.vue @@ -0,0 +1,68 @@ + + + diff --git a/resources/js/components/gallery/albumModule/AlbumHero.vue b/resources/js/components/gallery/albumModule/AlbumHero.vue index 6b1f8c83142..056c0d6d665 100644 --- a/resources/js/components/gallery/albumModule/AlbumHero.vue +++ b/resources/js/components/gallery/albumModule/AlbumHero.vue @@ -108,6 +108,14 @@ > + + + + + +
+ +
+
- - -
-
+ @@ -132,16 +205,21 @@ import Toolbar from "primevue/toolbar"; import ProgressSpinner from "primevue/progressspinner"; import Button from "primevue/button"; import Checkbox from "primevue/checkbox"; -import InputText from "primevue/inputtext"; +import AutoComplete from "primevue/autocomplete"; +import Dialog from "primevue/dialog"; import { useToast } from "primevue/usetoast"; import { trans } from "laravel-vue-i18n"; import OpenLeftMenu from "@/components/headers/OpenLeftMenu.vue"; +import PaginationInfiniteScroll from "@/components/pagination/PaginationInfiniteScroll.vue"; import FaceClusterService from "@/services/face-cluster-service"; +import PeopleService from "@/services/people-service"; +import FaceDetectionService from "@/services/face-detection-service"; const toast = useToast(); const clusters = ref([]); -const clusterNames = reactive>({}); +// Per-cluster selected person (string name or PersonResource) +const clusterPersonSelect = reactive>({}); const loading = ref(false); const loadingMore = ref(false); const runningClustering = ref(false); @@ -150,11 +228,59 @@ const dismissingLabel = ref(null); const currentPage = ref(1); const hasMorePages = ref(false); +// All known persons for autocomplete +const allPeople = ref([]); +const personSuggestions = ref([]); + // Batch selection state const isBatchMode = ref(false); const selectedLabels = ref([]); const batchDismissing = ref(false); +// Cluster detail dialog state +const detailDialogVisible = ref(false); +const detailCluster = ref(null); +const detailFaces = ref([]); +const detailFacesLoading = ref(false); +const detailPersonSelect = ref(null); +const detailAssigning = ref(false); +const detailDismissing = ref(false); + +function searchPeople(event: { query: string }) { + const q = event.query.toLowerCase(); + personSuggestions.value = allPeople.value.filter((p) => p.name.toLowerCase().includes(q)); + // If no match, allow free-text entry by treating the query as a new name + if (personSuggestions.value.length === 0 && q.trim().length > 0) { + // Add a pseudo-option so forceSelection doesn't block + personSuggestions.value = [ + { + id: "", + name: q.trim(), + photo_count: 0, + face_count: 0, + representative_crop_url: null, + representative_face_id: null, + is_searchable: true, + user_id: null, + }, + ]; + } +} + +function getClusterAssignName(label: number): string | null { + const v = clusterPersonSelect[label]; + if (!v) return null; + if (typeof v === "string") return v.trim() || null; + return v.name ?? null; +} + +function getDetailAssignName(): string | null { + const v = detailPersonSelect.value; + if (!v) return null; + if (typeof v === "string") return v.trim() || null; + return v.name ?? null; +} + function startBatchMode() { isBatchMode.value = true; selectedLabels.value = []; @@ -201,6 +327,16 @@ function batchDismiss() { }); } +function loadPeople() { + PeopleService.getPeople(1) + .then((response) => { + allPeople.value = response.data.persons; + }) + .catch(() => { + /* non-critical */ + }); +} + function load() { loading.value = true; FaceClusterService.getClusters(1) @@ -239,21 +375,33 @@ function loadMore() { }); } -function assignCluster(label: number) { - const name = clusterNames[label]?.trim(); - if (!name) return; +function resolveAssignPayload(v: App.Http.Resources.Models.PersonResource | string | null): { person_id?: string; new_person_name?: string } { + if (!v) return {}; + if (typeof v === "string") return { new_person_name: v.trim() }; + if (v.id) return { person_id: v.id }; + return { new_person_name: v.name }; +} + +function assignClusterWithSelection(label: number) { + const v = clusterPersonSelect[label]; + if (!v) return; + const payload = resolveAssignPayload(v); + if (!payload.person_id && !payload.new_person_name) return; assigningLabel.value = label; - FaceClusterService.assignCluster(label, { new_person_name: name }) + FaceClusterService.assignCluster(label, payload) .then((response) => { + const name = typeof v === "string" ? v : v.name; toast.add({ severity: "success", summary: trans("toasts.success"), - detail: `Assigned ${response.data.assigned_count} face(s) to "${name}"`, + detail: trans("people.assigned_faces_to", { count: String(response.data.assigned_count), name }), life: 3000, }); clusters.value = clusters.value.filter((c) => c.cluster_label !== label); - delete clusterNames[label]; + delete clusterPersonSelect[label]; + // Refresh people list after potential new person + loadPeople(); }) .catch((e) => { console.error("Error assigning face cluster:", e); @@ -271,11 +419,14 @@ function dismissCluster(label: number) { toast.add({ severity: "info", summary: trans("toasts.success"), - detail: `Dismissed ${response.data.dismissed_count} face(s)`, + detail: trans("people.dismissed_faces", { count: String(response.data.dismissed_count) }), life: 3000, }); clusters.value = clusters.value.filter((c) => c.cluster_label !== label); - delete clusterNames[label]; + delete clusterPersonSelect[label]; + if (detailCluster.value?.cluster_label === label) { + detailDialogVisible.value = false; + } }) .catch((e) => { console.error("Error dismissing face cluster:", e); @@ -290,7 +441,7 @@ function runClustering() { runningClustering.value = true; FaceClusterService.runClustering() .then(() => { - toast.add({ severity: "success", summary: trans("toasts.success"), detail: "Clustering started. Reload when complete.", life: 5000 }); + toast.add({ severity: "success", summary: trans("toasts.success"), detail: trans("people.clustering_started"), life: 5000 }); setTimeout(() => load(), 3000); }) .catch((e) => { @@ -302,7 +453,92 @@ function runClustering() { }); } +// ── Cluster detail dialog ──────────────────────────────────────── + +function openClusterDetail(cluster: App.Http.Resources.Models.ClusterPreviewResource) { + detailCluster.value = cluster; + detailFaces.value = []; + detailPersonSelect.value = null; + detailDialogVisible.value = true; + detailFacesLoading.value = true; + FaceClusterService.getClusterFaces(cluster.cluster_label) + .then((response) => { + detailFaces.value = response.data.data as unknown as App.Http.Resources.Models.FaceResource[]; + }) + .catch(() => { + /* already handled elsewhere */ + }) + .finally(() => { + detailFacesLoading.value = false; + }); +} + +function dismissSingleFace(face: App.Http.Resources.Models.FaceResource) { + FaceDetectionService.toggleDismissed(face.id) + .then(() => { + detailFaces.value = detailFaces.value.filter((f) => f.id !== face.id); + const cluster = clusters.value.find((c) => c.cluster_label === detailCluster.value?.cluster_label); + if (cluster) { + cluster.face_count = Math.max(0, cluster.face_count - 1); + } + }) + .catch((e: { response?: { data?: { message?: string } } }) => { + toast.add({ severity: "error", summary: trans("toasts.error"), detail: e.response?.data?.message, life: 3000 }); + }); +} + +function assignDetailCluster() { + if (!detailCluster.value) return; + const label = detailCluster.value.cluster_label; + const payload = resolveAssignPayload(detailPersonSelect.value); + if (!payload.person_id && !payload.new_person_name) return; + + detailAssigning.value = true; + FaceClusterService.assignCluster(label, payload) + .then((response) => { + toast.add({ + severity: "success", + summary: trans("toasts.success"), + detail: trans("people.assigned_faces", { count: String(response.data.assigned_count) }), + life: 3000, + }); + clusters.value = clusters.value.filter((c) => c.cluster_label !== label); + detailDialogVisible.value = false; + loadPeople(); + }) + .catch((e: { response?: { data?: { message?: string } } }) => { + toast.add({ severity: "error", summary: trans("toasts.error"), detail: e.response?.data?.message, life: 3000 }); + }) + .finally(() => { + detailAssigning.value = false; + }); +} + +function dismissDetailCluster() { + if (!detailCluster.value) return; + const label = detailCluster.value.cluster_label; + detailDismissing.value = true; + FaceClusterService.dismissCluster(label) + .then((response) => { + toast.add({ + severity: "info", + summary: trans("toasts.success"), + detail: trans("people.dismissed_faces", { count: String(response.data.dismissed_count) }), + life: 3000, + }); + clusters.value = clusters.value.filter((c) => c.cluster_label !== label); + detailDialogVisible.value = false; + }) + .catch((e: { response?: { data?: { message?: string } } }) => { + toast.add({ severity: "error", summary: trans("toasts.error"), detail: e.response?.data?.message, life: 3000 }); + }) + .finally(() => { + detailDismissing.value = false; + }); +} + onMounted(() => { load(); + loadPeople(); }); diff --git a/resources/js/views/face-recog/FaceMaintenance.vue b/resources/js/views/face-recog/FaceMaintenance.vue new file mode 100644 index 00000000000..700ecb56052 --- /dev/null +++ b/resources/js/views/face-recog/FaceMaintenance.vue @@ -0,0 +1,323 @@ + + diff --git a/resources/js/views/face-recog/People.vue b/resources/js/views/face-recog/People.vue index 74727201398..4f9575ac5a2 100644 --- a/resources/js/views/face-recog/People.vue +++ b/resources/js/views/face-recog/People.vue @@ -27,13 +27,35 @@ {{ $t("people.no_people") }} -
- +
+
+ + + + + +
+ +
+
+
+
@@ -42,13 +64,22 @@ import { ref, onMounted } from "vue"; import Toolbar from "primevue/toolbar"; import ProgressSpinner from "primevue/progressspinner"; import Button from "primevue/button"; +import ContextMenu from "primevue/contextmenu"; +import AutoComplete from "primevue/autocomplete"; +import Dialog from "primevue/dialog"; +import type { MenuItem } from "primevue/menuitem"; import { useToast } from "primevue/usetoast"; import { trans } from "laravel-vue-i18n"; import OpenLeftMenu from "@/components/headers/OpenLeftMenu.vue"; import PersonCard from "@/components/gallery/PersonCard.vue"; import PeopleService from "@/services/people-service"; +import UserManagementService from "@/services/user-management-service"; +import { useLeftMenuStateStore } from "@/stores/LeftMenuState"; +import { storeToRefs } from "pinia"; const toast = useToast(); +const leftMenuStore = useLeftMenuStateStore(); +const { initData } = storeToRefs(leftMenuStore); const people = ref([]); const loading = ref(false); @@ -56,6 +87,89 @@ const loadingMore = ref(false); const currentPage = ref(1); const hasMorePages = ref(false); +// Context menu +const contextMenuRef = ref | null>(null); +const contextMenuPerson = ref(null); +const contextMenuItems = ref([]); + +// User picker +const userPickerVisible = ref(false); +const allUsers = ref([]); +const userSuggestions = ref([]); +const selectedUser = ref(null); + +function buildContextMenuItems(person: App.Http.Resources.Models.PersonResource): MenuItem[] { + const items: MenuItem[] = [ + { + label: trans("people.person.toggle_searchable"), + icon: person.is_searchable ? "pi pi-eye-slash" : "pi pi-eye", + command: () => toggleSearchable(person), + }, + ]; + + if (initData.value?.user_management.can_edit) { + items.push({ + label: trans("people.assign_to_user"), + icon: "pi pi-user-edit", + command: () => openUserPicker(person), + }); + } + + return items; +} + +function openContextMenu(event: MouseEvent, person: App.Http.Resources.Models.PersonResource) { + contextMenuPerson.value = person; + contextMenuItems.value = buildContextMenuItems(person); + contextMenuRef.value?.show(event); +} + +function toggleSearchable(person: App.Http.Resources.Models.PersonResource) { + PeopleService.update(person.id, { is_searchable: !person.is_searchable }) + .then((response) => { + const idx = people.value.findIndex((p) => p.id === person.id); + if (idx !== -1) people.value[idx] = response.data; + toast.add({ severity: "success", summary: trans("toasts.success"), life: 3000 }); + }) + .catch((e) => { + toast.add({ severity: "error", summary: trans("toasts.error"), detail: e.response?.data?.message, life: 3000 }); + }); +} + +function openUserPicker(person: App.Http.Resources.Models.PersonResource) { + contextMenuPerson.value = person; + selectedUser.value = null; + if (allUsers.value.length === 0) { + UserManagementService.get() + .then((response) => { + allUsers.value = response.data; + }) + .catch(() => { + /* ignore */ + }); + } + userPickerVisible.value = true; +} + +function searchUsers(event: { query: string }) { + const q = event.query.toLowerCase(); + userSuggestions.value = allUsers.value.filter((u) => u.username.toLowerCase().includes(q)); +} + +function confirmAssignUser() { + if (!contextMenuPerson.value || !selectedUser.value) return; + const personId = contextMenuPerson.value.id; + const userId = selectedUser.value.id; + userPickerVisible.value = false; + PeopleService.update(personId, { user_id: userId }) + .then(() => { + toast.add({ severity: "success", summary: trans("toasts.success"), life: 3000 }); + }) + .catch((e) => { + toast.add({ severity: "error", summary: trans("toasts.error"), detail: e.response?.data?.message, life: 3000 }); + }); +} + function load() { loading.value = true; PeopleService.getPeople(1) diff --git a/resources/js/views/face-recog/PersonDetail.vue b/resources/js/views/face-recog/PersonDetail.vue index 283042e99d5..e2383682043 100644 --- a/resources/js/views/face-recog/PersonDetail.vue +++ b/resources/js/views/face-recog/PersonDetail.vue @@ -9,49 +9,7 @@ - +
@@ -71,32 +29,77 @@
-

{{ person.name }}

+

+ {{ person.name }} + +

+
{{ person.photo_count }} {{ $t("people.photos_label") }} • {{ person.face_count }} {{ $t("people.faces_label") }}
-
- +
+
- -
- -
-
-
+ + + + diff --git a/routes/api_v2.php b/routes/api_v2.php index 8499d7528b4..917ec459069 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -384,6 +384,8 @@ Route::post('/Face/batch', [AiVision\FaceController::class, 'batch'])->middleware(['support:se']); Route::patch('/Face/{id}', [AiVision\FaceController::class, 'toggleDismissed'])->middleware(['support:se']); Route::delete('/Face/dismissed', [AiVision\FaceController::class, 'destroyDismissed'])->middleware(['support:se']); +Route::get('/Face/maintenance', [AiVision\FaceMaintenanceController::class, 'index'])->middleware(['support:se']); +Route::post('/Face/maintenance/batch-dismiss', [AiVision\FaceMaintenanceController::class, 'batchDismiss'])->middleware(['support:se']); /** * AI VISION — FACE DETECTION. @@ -397,6 +399,7 @@ * AI VISION — CLUSTER REVIEW. */ Route::get('/FaceDetection/clusters', [AiVision\FaceClusterController::class, 'index'])->middleware(['support:se']); +Route::get('/FaceDetection/clusters/{label}/faces', [AiVision\FaceClusterController::class, 'faces'])->middleware(['support:se']); Route::post('/FaceDetection/clusters/{label}/assign', [AiVision\FaceClusterController::class, 'assign'])->middleware(['support:se']); Route::post('/FaceDetection/clusters/{label}/dismiss', [AiVision\FaceClusterController::class, 'dismiss'])->middleware(['support:se']); Route::post('/FaceDetection/clusters/{label}/uncluster', [AiVision\FaceClusterController::class, 'uncluster'])->middleware(['support:se']); diff --git a/routes/web_v2.php b/routes/web_v2.php index be8cb641e7d..a169f348b14 100644 --- a/routes/web_v2.php +++ b/routes/web_v2.php @@ -33,7 +33,8 @@ Event::dispatch(new DiagnosingHealth()); return view('health-up'); -}); +})->withoutMiddleware(['admin_user:set']); // We do not require an admin user fot the health check, as it may be used to check if the application is up before any users are created. + Route::get('/octane-health', function () { $status = [ 'status' => 'healthy', @@ -69,6 +70,7 @@ Route::get('/diagnostics', VueController::class)->middleware(['migration:complete']); Route::get('/statistics', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/maintenance', VueController::class)->middleware(['migration:complete', 'login_required:always']); +Route::get('/maintenance/faces', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/users', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/user-groups', VueController::class)->middleware(['migration:complete', 'login_required:always']); Route::get('/settings/{tab?}', VueController::class)->middleware(['migration:complete', 'login_required:always']); diff --git a/tests/Feature_v2/AiVision/FaceClusterFacesTest.php b/tests/Feature_v2/AiVision/FaceClusterFacesTest.php new file mode 100644 index 00000000000..f854b85580f --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceClusterFacesTest.php @@ -0,0 +1,109 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + Configs::set('ai_vision_face_person_is_searchable_default', '1'); + } + + public function tearDown(): void + { + DB::table('face_suggestions')->delete(); + DB::table('faces')->delete(); + DB::table('persons')->delete(); + $this->resetSe(); + parent::tearDown(); + } + + // ── GET /FaceDetection/clusters/{label}/faces ──────────────── + + public function testGetFacesForCluster(): void + { + Face::factory()->for_photo($this->photo1)->with_cluster(10)->count(3)->create(); + + $response = $this->actingAs($this->userMayUpload1)->getJson('FaceDetection/clusters/10/faces'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(3, $data); + } + + public function testGetFacesForClusterReturns404ForUnknownCluster(): void + { + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/9999/faces'); + $this->assertNotFound($response); + } + + public function testGetFacesExcludesAssignedFaces(): void + { + $person = Person::factory()->create(); + Face::factory()->for_photo($this->photo1)->for_person($person)->with_cluster(20)->create(); + // Only an unassigned face in cluster 20 should be returned + Face::factory()->for_photo($this->photo1)->with_cluster(20)->create(); + + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/20/faces'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(1, $data); + self::assertNull($data[0]['person_id']); + } + + public function testGetFacesExcludesDismissedFaces(): void + { + Face::factory()->for_photo($this->photo1)->dismissed()->with_cluster(30)->create(); + // The dismissed face above should cause 404 since no qualifying faces remain + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/30/faces'); + $this->assertNotFound($response); + } + + public function testGetFacesAsGuestUnauthorized(): void + { + $response = $this->getJson('FaceDetection/clusters/10/faces'); + $this->assertUnauthorized($response); + } + + public function testGetFacesPaginated(): void + { + Face::factory()->for_photo($this->photo1)->with_cluster(40)->count(5)->create(); + + $response = $this->actingAs($this->admin)->getJson('FaceDetection/clusters/40/faces?page=1'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(5, $data); + + $firstFace = $data[0]; + self::assertArrayHasKey('id', $firstFace); + self::assertArrayHasKey('photo_id', $firstFace); + self::assertArrayHasKey('confidence', $firstFace); + self::assertArrayHasKey('is_dismissed', $firstFace); + self::assertFalse($firstFace['is_dismissed']); + } +} diff --git a/tests/Feature_v2/AiVision/FaceMaintenanceTest.php b/tests/Feature_v2/AiVision/FaceMaintenanceTest.php new file mode 100644 index 00000000000..5d98a71c97b --- /dev/null +++ b/tests/Feature_v2/AiVision/FaceMaintenanceTest.php @@ -0,0 +1,128 @@ +requireSe(); + + Configs::set('ai_vision_enabled', '1'); + Configs::set('ai_vision_face_enabled', '1'); + Configs::set('ai_vision_face_permission_mode', 'public'); + + $this->admin = User::factory()->may_administrate()->create(); + $this->nonAdmin = User::factory()->create(); + $this->photo = Photo::factory()->create(['owner_id' => $this->admin->id]); + } + + public function tearDown(): void + { + $this->resetSe(); + parent::tearDown(); + } + + // ── GET /Face/maintenance ───────────────────────────────── + + public function testListFacesSortedByConfidenceAscending(): void + { + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.5, 'laplacian_variance' => 100.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.9, 'laplacian_variance' => 50.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.7, 'laplacian_variance' => 200.0]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance?sort_by=confidence&sort_dir=asc'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(3, $data); + self::assertLessThanOrEqual($data[1]['confidence'], $data[0]['confidence']); + self::assertLessThanOrEqual($data[2]['confidence'], $data[1]['confidence']); + } + + public function testListFacesSortedByLaplacianVarianceAscending(): void + { + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.8, 'laplacian_variance' => 300.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.6, 'laplacian_variance' => 10.0]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.7, 'laplacian_variance' => 150.0]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance?sort_by=laplacian_variance&sort_dir=asc'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(3, $data); + self::assertLessThanOrEqual($data[1]['laplacian_variance'], $data[0]['laplacian_variance']); + self::assertLessThanOrEqual($data[2]['laplacian_variance'], $data[1]['laplacian_variance']); + } + + public function testDefaultSortIsConfidenceAsc(): void + { + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.2]); + Face::factory()->for_photo($this->photo)->create(['confidence' => 0.9]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(2, $data); + // Default sort_by=confidence, sort_dir=asc: lowest confidence first + self::assertLessThanOrEqual($data[1]['confidence'], $data[0]['confidence']); + } + + public function testPaginationWorks(): void + { + Face::factory()->for_photo($this->photo)->count(5)->create(); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance?per_page=2&page=1'); + $this->assertOk($response); + + $data = $response->json('data'); + $meta = $response->json('meta'); + self::assertCount(2, $data); + self::assertEquals(5, $meta['total']); + self::assertEquals(2, $meta['per_page']); + self::assertEquals(3, $meta['last_page']); + } + + public function testAdminOnlyNonAdminGetsForbidden(): void + { + $response = $this->actingAs($this->nonAdmin)->getJson('Face/maintenance'); + $this->assertForbidden($response); + } + + public function testResponseIncludesPersonNameAndClusterLabel(): void + { + Face::factory()->for_photo($this->photo)->with_cluster(7)->create(['confidence' => 0.8]); + + $response = $this->actingAs($this->admin)->getJson('Face/maintenance'); + $this->assertOk($response); + + $data = $response->json('data'); + self::assertCount(1, $data); + self::assertEquals(0.8, $data[0]['confidence']); + self::assertEquals(7, $data[0]['cluster_label']); + self::assertArrayHasKey('laplacian_variance', $data[0]); + } +} diff --git a/tests/Feature_v2/AiVision/PersonPhotosTest.php b/tests/Feature_v2/AiVision/PersonPhotosTest.php index e52985c5515..0ea0e7013b2 100644 --- a/tests/Feature_v2/AiVision/PersonPhotosTest.php +++ b/tests/Feature_v2/AiVision/PersonPhotosTest.php @@ -51,7 +51,7 @@ public function testListPhotosForPerson(): void $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id . '/photos'); $this->assertOk($response); - $photo_ids = collect($response->json('data'))->pluck('id')->all(); + $photo_ids = collect($response->json('photos'))->pluck('id')->all(); self::assertContains($this->photo1->id, $photo_ids); } @@ -59,7 +59,31 @@ public function testEmptyResultForPersonWithNoFaces(): void { $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id . '/photos'); $this->assertOk($response); - self::assertEmpty($response->json('data')); + self::assertEmpty($response->json('photos')); + } + + public function testNextPreviousIdsAreSetRelativeToPersonCollection(): void + { + Face::factory()->for_photo($this->photo1)->for_person($this->person1)->create(); + Face::factory()->for_photo($this->photo2)->for_person($this->person1)->create(); + Face::factory()->for_photo($this->photo3)->for_person($this->person1)->create(); + + $response = $this->actingAs($this->admin)->getJson('Person/' . $this->person1->id . '/photos'); + $this->assertOk($response); + + $photos = $response->json('photos'); + self::assertCount(3, $photos); + + // First photo: previous_photo_id must be null + self::assertNull($photos[0]['previous_photo_id']); + // First photo points forward to second + self::assertSame($photos[1]['id'], $photos[0]['next_photo_id']); + // Middle photo chained correctly + self::assertSame($photos[0]['id'], $photos[1]['previous_photo_id']); + self::assertSame($photos[2]['id'], $photos[1]['next_photo_id']); + // Last photo: next_photo_id must be null + self::assertNull($photos[2]['next_photo_id']); + self::assertSame($photos[1]['id'], $photos[2]['previous_photo_id']); } public function testNonSearchablePersonForbiddenForNonOwner(): void From 20dc32f57de878d82d573b6271f36ca6dbb3d5a4 Mon Sep 17 00:00:00 2001 From: ildyria Date: Fri, 10 Apr 2026 00:20:46 +0200 Subject: [PATCH 06/36] more work --- .../face-recognition/app/api/routes.py | 25 ++ .../app/embeddings/sqlite_store.py | 12 + .../face-recognition/app/embeddings/store.py | 11 + app/Models/Face.php | 4 - .../2026_03_21_000003_ai_vision_config.php | 2 +- .../js/views/face-recog/FaceMaintenance.vue | 364 +++++++++++------- 6 files changed, 283 insertions(+), 135 deletions(-) diff --git a/ai-vision-service/face-recognition/app/api/routes.py b/ai-vision-service/face-recognition/app/api/routes.py index f8c579cb69a..734f8ec1fb9 100644 --- a/ai-vision-service/face-recognition/app/api/routes.py +++ b/ai-vision-service/face-recognition/app/api/routes.py @@ -273,6 +273,31 @@ async def _run_detection_job( """ logger.info("Starting detection job for photo_id=%s, path=%s", photo_id, image_path) try: + # --- 0. Check if faces already exist for this photo --- + existing_count = store.count_by_photo_id(photo_id) + if existing_count > 0: + logger.info( + "Skipping detection for photo_id=%s: %d face(s) already processed", + photo_id, + existing_count, + ) + # Send empty success callback to indicate no new faces detected + payload = DetectCallbackPayload(photo_id=photo_id, faces=[]) + callback_url = f"{settings.lychee_api_url}/api/v2/FaceDetection/results" + async with httpx.AsyncClient(verify=settings.verify_ssl) as client: + response = await client.post( + callback_url, + json=payload.model_dump(), + headers={ + "X-API-Key": settings.api_key, + "Content-Type": "application/json", + "Accept": "application/json", + }, + timeout=30.0, + ) + response.raise_for_status() + return + loop = asyncio.get_running_loop() # --- 1. Detect faces (CPU-bound, runs in thread pool) --- diff --git a/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py index cc1e0942cfd..680c2b91155 100644 --- a/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py +++ b/ai-vision-service/face-recognition/app/embeddings/sqlite_store.py @@ -198,6 +198,18 @@ def count(self) -> int: finally: conn.close() + def count_by_photo_id(self, photo_id: str) -> int: + """Count how many faces have been stored for a given photo.""" + conn = self._connect() + try: + row = conn.execute( + "SELECT COUNT(*) FROM face_meta WHERE photo_id = ?", + [photo_id], + ).fetchone() + return int(row[0]) if row else 0 + finally: + conn.close() + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ diff --git a/ai-vision-service/face-recognition/app/embeddings/store.py b/ai-vision-service/face-recognition/app/embeddings/store.py index 0f56f11eb47..97c40fdaf5a 100644 --- a/ai-vision-service/face-recognition/app/embeddings/store.py +++ b/ai-vision-service/face-recognition/app/embeddings/store.py @@ -98,6 +98,17 @@ def get_all_with_metadata(self) -> list[dict[str, str | float | None]]: """ ... + def count_by_photo_id(self, photo_id: str) -> int: + """Count how many faces have been stored for a given photo. + + Args: + photo_id: Lychee photo ID to check. + + Returns: + Number of face embeddings stored for this photo. + """ + ... + def count(self) -> int: """Return the total number of stored embeddings.""" ... diff --git a/app/Models/Face.php b/app/Models/Face.php index 4b2348ec92d..b0a3475e428 100644 --- a/app/Models/Face.php +++ b/app/Models/Face.php @@ -84,10 +84,6 @@ class Face extends Model 'crop_url', ]; - protected $with = [ - 'person', - ]; - /** * @return array */ diff --git a/database/migrations/2026_03_21_000003_ai_vision_config.php b/database/migrations/2026_03_21_000003_ai_vision_config.php index f30d5b6d355..4cd445458d7 100644 --- a/database/migrations/2026_03_21_000003_ai_vision_config.php +++ b/database/migrations/2026_03_21_000003_ai_vision_config.php @@ -45,7 +45,7 @@ public function getConfigs(): array 'type_range' => 'public|private|privacy-preserving|restricted', 'description' => 'Permission mode for facial recognition features', 'details' => 'Controls who can view people, face overlays, and manage faces. Options: public, private, privacy-preserving, restricted.', - 'is_expert' => true, + 'is_expert' => false, 'is_secret' => false, 'level' => 1, 'order' => 12, diff --git a/resources/js/views/face-recog/FaceMaintenance.vue b/resources/js/views/face-recog/FaceMaintenance.vue index 700ecb56052..b8b253838c1 100644 --- a/resources/js/views/face-recog/FaceMaintenance.vue +++ b/resources/js/views/face-recog/FaceMaintenance.vue @@ -9,156 +9,209 @@ -
-

- {{ $t("maintenance.face_quality.description") }} -

- - -
- {{ $t("maintenance.face_quality.sort_by") }} -
-
+ + + + + +
+ +
+
+ +
+
Taken:
+
{{ viewingPhoto.taken_at || "Unknown" }}
+
Dimensions:
+
{{ viewingPhoto.size_variants.original?.width || "?" }} × {{ viewingPhoto.size_variants.original?.height || "?" }}
+
+
Description:
+
{{ viewingPhoto.description }}
+
+
+
+
diff --git a/resources/js/views/face-recog/People.vue b/resources/js/views/face-recog/People.vue index 4f9575ac5a2..8ad90e3a49a 100644 --- a/resources/js/views/face-recog/People.vue +++ b/resources/js/views/face-recog/People.vue @@ -37,6 +37,8 @@ + +
@@ -72,6 +74,7 @@ import { useToast } from "primevue/usetoast"; import { trans } from "laravel-vue-i18n"; import OpenLeftMenu from "@/components/headers/OpenLeftMenu.vue"; import PersonCard from "@/components/gallery/PersonCard.vue"; +import PersonDeleteDialog from "@/components/forms/people/PersonDeleteDialog.vue"; import PeopleService from "@/services/people-service"; import UserManagementService from "@/services/user-management-service"; import { useLeftMenuStateStore } from "@/stores/LeftMenuState"; @@ -92,6 +95,9 @@ const contextMenuRef = ref | null>(null); const contextMenuPerson = ref(null); const contextMenuItems = ref([]); +// Delete dialog +const deletePersonVisible = ref(false); + // User picker const userPickerVisible = ref(false); const allUsers = ref([]); @@ -115,6 +121,12 @@ function buildContextMenuItems(person: App.Http.Resources.Models.PersonResource) }); } + items.push({ + label: trans("people.person.delete"), + icon: "pi pi-trash", + command: () => openDeleteDialog(person), + }); + return items; } @@ -156,6 +168,17 @@ function searchUsers(event: { query: string }) { userSuggestions.value = allUsers.value.filter((u) => u.username.toLowerCase().includes(q)); } +function openDeleteDialog(person: App.Http.Resources.Models.PersonResource) { + contextMenuPerson.value = person; + deletePersonVisible.value = true; +} + +function onPersonDeleted() { + if (contextMenuPerson.value) { + people.value = people.value.filter((p) => p.id !== contextMenuPerson.value!.id); + } +} + function confirmAssignUser() { if (!contextMenuPerson.value || !selectedUser.value) return; const personId = contextMenuPerson.value.id; diff --git a/resources/js/views/face-recog/PersonDetail.vue b/resources/js/views/face-recog/PersonDetail.vue index e2383682043..f0dc6420832 100644 --- a/resources/js/views/face-recog/PersonDetail.vue +++ b/resources/js/views/face-recog/PersonDetail.vue @@ -154,7 +154,7 @@ - + @@ -186,15 +186,14 @@ import Button from "primevue/button"; import Checkbox from "primevue/checkbox"; import InputText from "primevue/inputtext"; import Tag from "primevue/tag"; -import ConfirmDialog from "primevue/confirmdialog"; import { useToast } from "primevue/usetoast"; -import { useConfirm } from "primevue/useconfirm"; import { trans } from "laravel-vue-i18n"; import { useDebounceFn, onKeyStroke } from "@vueuse/core"; import createJustifiedLayout from "justified-layout"; import MergePersonModal from "@/components/modals/faceRecog/MergePersonModal.vue"; import PaginationInfiniteScroll from "@/components/pagination/PaginationInfiniteScroll.vue"; import PhotoPanel from "@/components/gallery/photoModule/PhotoPanel.vue"; +import PersonDeleteDialog from "@/components/forms/people/PersonDeleteDialog.vue"; import PeopleService from "@/services/people-service"; import FaceBatchService from "@/services/face-batch-service"; import { useUserStore } from "@/stores/UserState"; @@ -212,7 +211,6 @@ const props = defineProps<{ personId: string; photoId?: string }>(); const router = useRouter(); const toast = useToast(); -const confirm = useConfirm(); const userStore = useUserStore(); const { user } = storeToRefs(userStore); const canEdit = ref(false); @@ -612,22 +610,14 @@ function toggleSearchable() { }); } +// Merge modal +const isMergeModalOpen = ref(false); + +// Delete dialog +const deletePersonVisible = ref(false); + function confirmDelete() { - confirm.require({ - message: trans("dialogs.delete_confirm"), - header: trans("dialogs.delete"), - icon: "pi pi-trash", - acceptClass: "p-button-danger", - accept() { - PeopleService.destroy(props.personId) - .then(() => { - router.push({ name: "people" }); - }) - .catch((e) => { - toast.add({ severity: "error", summary: trans("toasts.error"), detail: e.response?.data?.message, life: 3000 }); - }); - }, - }); + deletePersonVisible.value = true; } onMounted(() => { @@ -649,9 +639,6 @@ onUnmounted(() => { document.removeEventListener("mouseup", onDragEnd); }); -// Merge modal -const isMergeModalOpen = ref(false); - function onMerged(targetPersonId: string) { router.push({ name: "person", params: { personId: targetPersonId } }); } From 4ef17a53714ee0f2d38cd3abfd5499d1be1a6a53 Mon Sep 17 00:00:00 2001 From: ildyria Date: Fri, 1 May 2026 14:40:04 +0200 Subject: [PATCH 26/36] Add configuration data --- .../face-recognition/.insightface/.gitignore | 2 - .../face-recognition/app/api/routes.py | 15 ++ .../face-recognition/app/api/schemas.py | 7 + .../face-recognition/app/config.py | 19 +++ .../face-recognition/tests/conftest.py | 26 ++++ .../face-recognition/tests/test_api.py | 30 ++++ app/Actions/Diagnostics/Errors.php | 2 + .../Checks/AiVisionServiceConfigCheck.php | 73 ++++++++++ .../AiVision/FaceClusterController.php | 5 + .../AiVision/FaceDetectionController.php | 29 +++- .../AiVision/FaceMaintenanceController.php | 2 + .../AiVision/PersonClaimController.php | 5 + app/Http/Resources/Models/FaceResource.php | 10 +- .../Image/FacialRecognitionService.php | 58 ++++++++ lang/en/maintenance.php | 8 ++ .../js/services/face-maintenance-service.ts | 1 + .../js/views/face-recog/FaceMaintenance.vue | 102 +++++++++++++- .../Feature_v2/AiVision/FaceDetectionTest.php | 28 ++++ .../AiVision/FaceMaintenanceTest.php | 31 ++++ .../AiVisionServiceConfigCheckTest.php | 133 ++++++++++++++++++ 20 files changed, 577 insertions(+), 9 deletions(-) delete mode 100644 ai-vision-service/face-recognition/.insightface/.gitignore create mode 100644 app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceConfigCheck.php create mode 100644 tests/Unit/Actions/Diagnostics/AiVisionServiceConfigCheckTest.php diff --git a/ai-vision-service/face-recognition/.insightface/.gitignore b/ai-vision-service/face-recognition/.insightface/.gitignore deleted file mode 100644 index c96a04f008e..00000000000 --- a/ai-vision-service/face-recognition/.insightface/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/ai-vision-service/face-recognition/app/api/routes.py b/ai-vision-service/face-recognition/app/api/routes.py index 25b1779d23e..63d9151e029 100644 --- a/ai-vision-service/face-recognition/app/api/routes.py +++ b/ai-vision-service/face-recognition/app/api/routes.py @@ -4,6 +4,7 @@ POST /detect - Accept a face-detection job; run async, callback to Lychee. POST /match - Accept a selfie; return top-N similar stored faces. GET /health - Return service health and embedding count. + GET /config - Return current runtime configuration values. """ from __future__ import annotations @@ -35,6 +36,7 @@ HealthResponse, MatchResponse, MatchResult, + ServiceConfigResponse, SuggestionResult, ) from app.clustering.clusterer import FaceClusterer @@ -263,6 +265,19 @@ async def health(request: Request) -> HealthResponse: ) +@router.get("/config") +async def service_config( + settings: AppSettings = Depends(get_settings), + _: None = Depends(require_api_key), +) -> ServiceConfigResponse: + """Return current runtime configuration values. + + The endpoint is authenticated because it exposes operational details. + Sensitive values are redacted by ``AppSettings.to_diagnostics_payload``. + """ + return ServiceConfigResponse(config=settings.to_diagnostics_payload()) + + # --------------------------------------------------------------------------- # Background detection job # --------------------------------------------------------------------------- diff --git a/ai-vision-service/face-recognition/app/api/schemas.py b/ai-vision-service/face-recognition/app/api/schemas.py index 9af12428197..cdac4f7b535 100644 --- a/ai-vision-service/face-recognition/app/api/schemas.py +++ b/ai-vision-service/face-recognition/app/api/schemas.py @@ -239,6 +239,13 @@ class HealthResponse(BaseModel): """Total number of face embeddings currently stored.""" +class ServiceConfigResponse(BaseModel): + """Response body for ``GET /config``.""" + + config: dict[str, str] + """Current runtime configuration values as strings (with secrets redacted).""" + + # --------------------------------------------------------------------------- # GET /embeddings/export - Python -> Lychee (for sync) # --------------------------------------------------------------------------- diff --git a/ai-vision-service/face-recognition/app/config.py b/ai-vision-service/face-recognition/app/config.py index e3830966410..58793813a58 100644 --- a/ai-vision-service/face-recognition/app/config.py +++ b/ai-vision-service/face-recognition/app/config.py @@ -122,6 +122,25 @@ class AppSettings(BaseSettings): extra="ignore", # Ignore extra fields (e.g., from Lychee's .env when running from main project) ) + def to_diagnostics_payload(self) -> dict[str, str]: + """Return settings as a diagnostics-safe mapping. + + Only non-sensitive operational settings are exposed via diagnostics. + """ + return { + "blur_threshold": str(self.blur_threshold), + "cluster_eps": str(self.cluster_eps), + "detection_threshold": str(self.detection_threshold), + "detector_backend": str(self.detector_backend), + "match_threshold": str(self.match_threshold), + "max_faces_per_photo": str(self.max_faces_per_photo), + "model_name": str(self.model_name), + "rescan_iou_threshold": str(self.rescan_iou_threshold), + "thread_pool_size": str(self.thread_pool_size), + "verify_ssl": str(self.verify_ssl), + "workers": str(self.workers), + } + @lru_cache def get_settings() -> AppSettings: diff --git a/ai-vision-service/face-recognition/tests/conftest.py b/ai-vision-service/face-recognition/tests/conftest.py index 1a4929602ca..c6e368cd6ea 100644 --- a/ai-vision-service/face-recognition/tests/conftest.py +++ b/ai-vision-service/face-recognition/tests/conftest.py @@ -41,6 +41,19 @@ def mock_settings() -> AppSettings: m.thread_pool_size = 1 m.storage_backend = "sqlite" m.log_level = "info" + m.to_diagnostics_payload.return_value = { + "blur_threshold": "0.5", + "cluster_eps": "0.45", + "detection_threshold": "0.5", + "detector_backend": "retinaface", + "match_threshold": "0.5", + "max_faces_per_photo": "20", + "model_name": "ArcFace", + "rescan_iou_threshold": "0.5", + "thread_pool_size": "1", + "verify_ssl": "False", + "workers": "1", + } return m @@ -114,6 +127,19 @@ def _override_settings() -> AppSettings: m.max_faces_per_photo = 10 m.detection_threshold = 0.5 m.thread_pool_size = 1 + m.to_diagnostics_payload.return_value = { + "blur_threshold": "0.5", + "cluster_eps": "0.45", + "detection_threshold": "0.5", + "detector_backend": "retinaface", + "match_threshold": "0.5", + "max_faces_per_photo": "20", + "model_name": "ArcFace", + "rescan_iou_threshold": "0.5", + "thread_pool_size": "1", + "verify_ssl": "False", + "workers": "1", + } return m # type: ignore[return-value] application.dependency_overrides[get_settings] = _override_settings diff --git a/ai-vision-service/face-recognition/tests/test_api.py b/ai-vision-service/face-recognition/tests/test_api.py index f1375cf4b04..31a6d44c9fb 100644 --- a/ai-vision-service/face-recognition/tests/test_api.py +++ b/ai-vision-service/face-recognition/tests/test_api.py @@ -67,6 +67,36 @@ def test_match_requires_api_key(client: TestClient) -> None: assert response.status_code == 422 # missing header +def test_config_requires_api_key(client: TestClient) -> None: + response = client.get("/config") + assert response.status_code == 422 # missing header + + +def test_config_returns_redacted_values(client: TestClient) -> None: + response = client.get("/config", headers={"X-API-Key": "test-api-key"}) + assert response.status_code == 200 + + payload = response.json() + assert "config" in payload + expected = { + "blur_threshold": "0.5", + "cluster_eps": "0.45", + "detection_threshold": "0.5", + "detector_backend": "retinaface", + "match_threshold": "0.5", + "max_faces_per_photo": "20", + "model_name": "ArcFace", + "rescan_iou_threshold": "0.5", + "thread_pool_size": "1", + "verify_ssl": "False", + "workers": "1", + } + assert payload["config"] == expected + assert "api_key" not in payload["config"] + assert "lychee_api_url" not in payload["config"] + assert "photos_path" not in payload["config"] + + # --------------------------------------------------------------------------- # POST /detect # --------------------------------------------------------------------------- diff --git a/app/Actions/Diagnostics/Errors.php b/app/Actions/Diagnostics/Errors.php index 59838e25e60..40192b17fb5 100644 --- a/app/Actions/Diagnostics/Errors.php +++ b/app/Actions/Diagnostics/Errors.php @@ -10,6 +10,7 @@ use App\Actions\Diagnostics\Pipes\Checks\AdminUserExistsCheck; use App\Actions\Diagnostics\Pipes\Checks\AiVisionServiceCheck; +use App\Actions\Diagnostics\Pipes\Checks\AiVisionServiceConfigCheck; use App\Actions\Diagnostics\Pipes\Checks\AppUrlMatchCheck; use App\Actions\Diagnostics\Pipes\Checks\AuthDisabledCheck; use App\Actions\Diagnostics\Pipes\Checks\BasicPermissionCheck; @@ -78,6 +79,7 @@ class Errors ImagickPdfCheck::class, WatermarkerEnabledCheck::class, AiVisionServiceCheck::class, + AiVisionServiceConfigCheck::class, StatisticsIntegrityCheck::class, WebshopCheck::class, SecurityAdvisoriesCheck::class, diff --git a/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceConfigCheck.php b/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceConfigCheck.php new file mode 100644 index 00000000000..2b815d63f4a --- /dev/null +++ b/app/Actions/Diagnostics/Pipes/Checks/AiVisionServiceConfigCheck.php @@ -0,0 +1,73 @@ +config_manager->getValueAsBool('ai_vision_enabled') !== true) { + return $next($data); + } + + if (!$this->facial_recognition_service->isConfigured()) { + return $next($data); + } + + $configuration = $this->facial_recognition_service->getConfiguration(); + if ($configuration === null) { + $data[] = DiagnosticData::warn( + 'AI Vision: Could not fetch runtime configuration from the service while APP_DEBUG is enabled.', + self::class, + [] + ); + + return $next($data); + } + + $details = []; + foreach ($configuration as $key => $value) { + $details[] = $key . ': ' . $value; + } + + $data[] = DiagnosticData::info( + 'AI Vision: Runtime configuration from service (debug mode).', + self::class, + $details + ); + + return $next($data); + } +} diff --git a/app/Http/Controllers/AiVision/FaceClusterController.php b/app/Http/Controllers/AiVision/FaceClusterController.php index 71d5359639b..b929d2b81c1 100644 --- a/app/Http/Controllers/AiVision/FaceClusterController.php +++ b/app/Http/Controllers/AiVision/FaceClusterController.php @@ -78,6 +78,11 @@ public function assign(ClusterAssignRequest $request, int $label, PersonFactory ->notDismissed() ->update(['person_id' => $person->id]); + // Bulk update bypasses FaceObserver, so recount denormalized counters manually. + $person->face_count = Face::where('person_id', '=', $person->id)->where('is_dismissed', '=', false)->count(); + $person->photo_count = Face::where('person_id', '=', $person->id)->where('is_dismissed', '=', false)->distinct('photo_id')->count('photo_id'); + $person->save(); + return ['assigned_count' => $count]; } diff --git a/app/Http/Controllers/AiVision/FaceDetectionController.php b/app/Http/Controllers/AiVision/FaceDetectionController.php index 9ba7f318e1b..74736339f96 100644 --- a/app/Http/Controllers/AiVision/FaceDetectionController.php +++ b/app/Http/Controllers/AiVision/FaceDetectionController.php @@ -15,6 +15,7 @@ use App\Http\Requests\Face\ScanPhotosRequest; use App\Models\Face; use App\Models\FaceSuggestion; +use App\Models\Person; use App\Models\Photo; use App\Services\Image\FaceDetectionService; use Illuminate\Routing\Controller; @@ -109,6 +110,8 @@ public function bulkScan(BulkScanRequest $request, FaceDetectionService $service public function clusterResults(ClusterResultsRequest $request): \Illuminate\Http\Response { DB::transaction(function () use ($request): void { + $affected_person_ids = []; + // Reset all existing cluster labels so stale assignments are removed. Face::whereNotNull('cluster_label')->update(['cluster_label' => null]); @@ -133,15 +136,39 @@ public function clusterResults(ClusterResultsRequest $request): \Illuminate\Http foreach ($clusters_with_person as $label => $persons) { if ($persons->count() === 1) { // All assigned faces in this cluster agree on a single person. + $person_id = $persons->first()->person_id; Face::where('cluster_label', '=', $label) ->whereNull('person_id') ->notDismissed() - ->update(['person_id' => $persons->first()->person_id]); + ->update(['person_id' => $person_id]); + $affected_person_ids[] = $person_id; } // If multiple persons share the same cluster (mis-clustering), // leave unassigned faces for manual review. } + if ($affected_person_ids !== []) { + /** @var string[] $distinct_person_ids */ + $distinct_person_ids = array_values(array_unique(array_filter($affected_person_ids, static fn ($person_id) => $person_id !== null))); + if ($distinct_person_ids !== []) { + $stats_by_person = Face::query() + ->whereIn('person_id', $distinct_person_ids) + ->where('is_dismissed', '=', false) + ->selectRaw('person_id, COUNT(*) as face_count, COUNT(DISTINCT photo_id) as photo_count') + ->groupBy('person_id') + ->get() + ->keyBy('person_id'); + + $persons = Person::query()->whereIn('id', $distinct_person_ids)->get(); + foreach ($persons as $person) { + $stats = $stats_by_person->get($person->id); + $person->face_count = $stats?->face_count ?? 0; + $person->photo_count = $stats?->photo_count ?? 0; + $person->save(); + } + } + } + // Upsert cross-cluster suggestions. foreach ($request->suggestions() as $sug) { FaceSuggestion::updateOrCreate( diff --git a/app/Http/Controllers/AiVision/FaceMaintenanceController.php b/app/Http/Controllers/AiVision/FaceMaintenanceController.php index 3f7f9d6fbc0..783efbea375 100644 --- a/app/Http/Controllers/AiVision/FaceMaintenanceController.php +++ b/app/Http/Controllers/AiVision/FaceMaintenanceController.php @@ -37,10 +37,12 @@ public function index(FaceMaintenanceIndexRequest $request): PaginatedFaceResour : 'confidence'; $sort_dir = $request->query('sort_dir') === 'desc' ? 'desc' : 'asc'; + $dismissed_only = filter_var($request->query('dismissed_only', false), FILTER_VALIDATE_BOOLEAN) === true; $per_page = max(1, min(200, (int) ($request->query('per_page', 50)))); $paginated = Face::with(['photo:id,title', 'person:id,name', 'suggestions']) + ->where('is_dismissed', '=', $dismissed_only) ->orderBy($sort_by, $sort_dir) ->paginate($per_page); diff --git a/app/Http/Controllers/AiVision/PersonClaimController.php b/app/Http/Controllers/AiVision/PersonClaimController.php index 61574a7d7eb..564386196bd 100644 --- a/app/Http/Controllers/AiVision/PersonClaimController.php +++ b/app/Http/Controllers/AiVision/PersonClaimController.php @@ -89,6 +89,11 @@ public function merge(MergePersonRequest $request, string $id): PersonResource // Delete source person $source->delete(); + // Bulk update bypasses FaceObserver, so recount denormalized counters manually. + $target->face_count = Face::where('person_id', '=', $target->id)->where('is_dismissed', '=', false)->count(); + $target->photo_count = Face::where('person_id', '=', $target->id)->where('is_dismissed', '=', false)->distinct('photo_id')->count('photo_id'); + $target->save(); + return PersonResource::fromModel($target->fresh()); } } diff --git a/app/Http/Resources/Models/FaceResource.php b/app/Http/Resources/Models/FaceResource.php index 8e5c6a95bc1..50172e69ac1 100644 --- a/app/Http/Resources/Models/FaceResource.php +++ b/app/Http/Resources/Models/FaceResource.php @@ -30,7 +30,7 @@ class FaceResource extends Data public ?string $crop_url; public ?string $person_name; /** @var FaceSuggestionResource[] */ - public array $suggestions; + public array $suggestions = []; public function __construct(Face $face) { @@ -47,10 +47,10 @@ public function __construct(Face $face) $this->cluster_label = $face->cluster_label; $this->crop_url = $face->crop_url; $this->person_name = $face->person?->name; - $this->suggestions = $face->suggestions - ->map(fn (FaceSuggestion $s) => new FaceSuggestionResource($s)) - ->values() - ->all(); + // $this->suggestions = $face->suggestions + // ->map(fn (FaceSuggestion $s) => new FaceSuggestionResource($s)) + // ->values() + // ->all(); } public static function fromModel(Face $face): self diff --git a/app/Services/Image/FacialRecognitionService.php b/app/Services/Image/FacialRecognitionService.php index 488d9b21e98..4713b06485f 100644 --- a/app/Services/Image/FacialRecognitionService.php +++ b/app/Services/Image/FacialRecognitionService.php @@ -148,6 +148,64 @@ public function checkHealth(): ?array } } + /** + * Make a raw HTTP request to the AI Vision service config endpoint. + * + * @param int $timeout Request timeout in seconds + * + * @return Response + * + * @throws ExternalComponentMissingException When the service is not configured + * @throws \Exception When the HTTP request fails + */ + public function getConfigurationRaw(int $timeout = 5): Response + { + if (!$this->isConfigured()) { + throw new ExternalComponentMissingException('AI Vision service is not configured.'); + } + + return Http::withHeaders(['X-API-Key' => $this->api_key]) + ->timeout($timeout) + ->get($this->service_url . '/config'); + } + + /** + * Retrieve the runtime configuration of the AI Vision service. + * + * @return array|null + */ + public function getConfiguration(): ?array + { + if (!$this->isConfigured()) { + Log::warning('FacialRecognitionService: getConfiguration called but service is not configured.'); + + return null; + } + + try { + $response = $this->getConfigurationRaw(); + if (!$response->successful()) { + return null; + } + + $payload = $response->json(); + if (!is_array($payload) || !isset($payload['config']) || !is_array($payload['config'])) { + return null; + } + + $config = []; + foreach ($payload['config'] as $key => $value) { + if (is_string($key)) { + $config[$key] = (string) $value; + } + } + + return $config; + } catch (\Exception) { + return null; + } + } + /** * Export all face embeddings with metadata for synchronization. * diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 5277bf68352..ecee3a506cb 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -115,12 +115,20 @@ 'col_actions' => 'Actions', 'unassigned' => 'Unassigned', 'dismiss' => 'Dismiss face', + 'readd' => 'Re-add face', 'load_error' => 'Failed to load faces.', 'dismissed' => 'Face dismissed.', + 'readded' => 'Face re-added.', 'dismiss_error' => 'Failed to dismiss face.', + 'readd_error' => 'Failed to re-add face.', 'batch_dismiss' => 'Dismiss selected', 'batch_dismissed' => ':count face(s) dismissed.', 'batch_dismiss_error' => 'Failed to dismiss selected faces.', + 'batch_reactivate' => 'Reactivate selected', + 'batch_reactivated' => ':count face(s) reactivated.', + 'batch_reactivate_error' => 'Failed to reactivate selected faces.', + 'show_dismissed' => 'Show dismissed', + 'show_active' => 'Show active', 'select_all' => 'Select all', 'deselect_all' => 'Deselect all', 'selected_count' => ':count selected', diff --git a/resources/js/services/face-maintenance-service.ts b/resources/js/services/face-maintenance-service.ts index 28be2bfe5bf..db582bc0b8a 100644 --- a/resources/js/services/face-maintenance-service.ts +++ b/resources/js/services/face-maintenance-service.ts @@ -4,6 +4,7 @@ import Constants from "./constants"; export type FaceMaintenanceParams = { sort_by?: "confidence" | "laplacian_variance"; sort_dir?: "asc" | "desc"; + dismissed_only?: boolean; page?: number; per_page?: number; }; diff --git a/resources/js/views/face-recog/FaceMaintenance.vue b/resources/js/views/face-recog/FaceMaintenance.vue index 8de806e748a..c41c20d9970 100644 --- a/resources/js/views/face-recog/FaceMaintenance.vue +++ b/resources/js/views/face-recog/FaceMaintenance.vue @@ -42,6 +42,15 @@ @click="setSort('laplacian_variance')" />
+
@@ -161,6 +181,7 @@ /> - +
+ +
+
-
+