From 892d0c5954ca3c148c46e6b0e4031c0407131b0b Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 28 Aug 2026 11:57:42 +0200 Subject: [PATCH] feat: freeze photo quality triage --- AGENTS.md | 3 +- README.md | 6 +- docs/architecture/photo_quality_triage.md | 62 ++++ docs/architecture/project_database.md | 12 + docs/architecture/task_system.md | 10 + docs/roadmap/roadmap.md | 56 ++- include/lardon3d/photo_quality.h | 86 +++++ include/lardon3d/photo_quality_task.h | 69 ++++ include/lardon3d/project.h | 6 + include/lardon3d/project_db.h | 56 ++- meson.build | 65 ++++ scan3d/tri_photos.py | 43 ++- src/photo_quality.cpp | 327 +++++++++++++++++ src/photo_quality_task.cpp | 255 +++++++++++++ src/project.c | 32 +- src/project_db.c | 407 ++++++++++++++++++++- src/task_kinds.c | 10 + tests/photo_quality_real_scan.cpp | 418 ++++++++++++++++++++++ tests/photo_quality_restart_scan.cpp | 350 ++++++++++++++++++ tests/test_geometric_verification.c | 9 +- tests/test_geometric_verifier_task.c | 2 +- tests/test_match_result.c | 11 +- tests/test_matcher_task.c | 3 +- tests/test_photo_quality.cpp | 166 +++++++++ tests/test_photo_quality_task.cpp | 322 +++++++++++++++++ tests/test_project_db.c | 52 +-- tests/test_sparse_sfm_model.c | 2 +- tests/test_sparse_sfm_payload.cpp | 2 +- 28 files changed, 2759 insertions(+), 83 deletions(-) create mode 100644 docs/architecture/photo_quality_triage.md create mode 100644 include/lardon3d/photo_quality.h create mode 100644 include/lardon3d/photo_quality_task.h create mode 100644 src/photo_quality.cpp create mode 100644 src/photo_quality_task.cpp create mode 100644 tests/photo_quality_real_scan.cpp create mode 100644 tests/photo_quality_restart_scan.cpp create mode 100644 tests/test_photo_quality.cpp create mode 100644 tests/test_photo_quality_task.cpp diff --git a/AGENTS.md b/AGENTS.md index 73619d1..00c59fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ through an explicitly authorized, explicitly scoped human ticket: - F0 — PASS/FROZEN - Phase H v1 — PASS/FROZEN - MVS-M1 — PASS/FROZEN -- Project DB v20 — PASS/FROZEN +- Project DB v21 — PASS/FROZEN +- Photo Quality Triage / Acquisition Selection — PASS/FROZEN - S1 Capture / Asset Provenance — PASS/FROZEN - S2 Capture-safe Standard Ingestion — PASS/FROZEN - S3 Capture / Acquisition Ingestion — PASS/FROZEN diff --git a/README.md b/README.md index 4bc88ec..2039a20 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,10 @@ persistante, enrichissable et versionnable. - **Image View** : vues triées et filtrées pour la TUI - **Task** : moteur de tâches avec pause/reprise, annulation et séquences - **Task Checkpoint v1** : snapshot durable, fichier atomique et reprise sûre -- **Project Database v20** : fondations v19 préservées, avec persistance - additive des tâches de campagne d'acquisition +- **Project Database v21** : fondations v20 préservées, avec persistance additive + des résultats de triage photo +- **[Photo Quality Triage](docs/architecture/photo_quality_triage.md)** : métriques JPEG + bornées et recommandations non destructives, avec persistance additive v21 - **Exécution durable de campagne d'acquisition** : tâche générique à requête typée immuable, confirmations `CALLER_EXPLICIT`, curseur et correspondance tâche/groupe→Capture persistants ; un groupe S3-E par séquence, reprise par diff --git a/docs/architecture/photo_quality_triage.md b/docs/architecture/photo_quality_triage.md new file mode 100644 index 0000000..9067907 --- /dev/null +++ b/docs/architecture/photo_quality_triage.md @@ -0,0 +1,62 @@ +# Photo Quality Triage / Acquisition Selection + +**PASS / FROZEN.** This non-destructive recommendation step runs +after acquisition discovery/minimal association and before materialization or features. +It consumes existing campaign groups and never treats path, basename, SHA-256, Task ID, +or group ID as Capture, Asset, or image identity. + +Metrics v1 validates JPEG structure with constant memory and a 64 MiB byte-work ceiling +covering every byte consumed or skipped, including marker payloads and entropy data. +Exceeding that parser-resource ceiling is a decode error; it is an operational protection, +not a JPEG image-size or scientific dataset limit. A structurally valid proxy above the operational +8192-pixel maximum-dimension decode ceiling returns `UNAVAILABLE + SUSPECT`, not a decode +error or REJECT, then accepted input uses OpenCV JPEG 1/2, 1/4, or 1/8 grayscale +reduction so the retained raster is at most 1024 pixels on its longest edge. This ceiling +only makes a proxy metric unavailable; it is not a campaign/source-count or scientific +dataset-size limit. The implementation records raw/normalized Laplacian sharpness, +black/white clipping, raw/normalized contrast, low-texture fraction, and stable reason +codes. Confirmed RAW+JPEG uses JPEG; RAW-only returns +`METRIC_UNAVAILABLE_REQUIRES_JPEG_PROXY` without RAW development. + +Recommendations are GOOD, SUSPECT, or REJECT. Default selection includes only GOOD. +Human NONE/INCLUDE/EXCLUDE override is separate and never rewrites measured metrics. + +La table de décision exacte Metrics v1 est : + +| Mesure/politique | Valeur exacte | Effet | +|---|---:|---| +| Netteté normalisée, très faible | `< 0.00018` | `REJECT`, raison `SHARPNESS_VERY_LOW` | +| Netteté normalisée, faible | `< 0.00045` | `SUSPECT`, raison `SHARPNESS_LOW` | +| Échantillon noir / blanc | `<= 5` / `>= 250` | Définit les fractions de clipping | +| Clipping blanc suspect / sévère | `> 0.08` / `> 0.20` | `SUSPECT` / `REJECT` | +| Clipping noir suspect / sévère | `> 0.15` / `> 0.35` | `SUSPECT` / `REJECT` | +| Contraste normalisé faible | `< 0.025` | `SUSPECT` | +| Magnitude Sobel de basse texture | `< 0.035` | Classe un pixel comme basse texture | +| Fraction de basse texture | `> 0.985` | `SUSPECT` | +| Aucun signal sévère ou suspect | — | `GOOD` | +| Au moins un signal suspect, aucun sévère | — | `SUSPECT` | +| Au moins un signal sévère | — | `REJECT` | +| Plus grand axe JPEG proxy | `8192` maximum | Au-delà : `UNAVAILABLE + SUSPECT` | +| Plus grand axe retenu pour analyse | `1024` maximum | Borne opérationnelle mémoire Metrics v1 | + +Les cutoffs de clipping conservent le prototype autorisé `scan3d/tri_photos.py` +(pourcentages 8/20 et 15/35 convertis en fractions). L'ensemble de la politique est une +calibration d'ingénierie déterministe validée par fixtures synthétiques et observations +réelles Sony A6000 et Samsung S21 ; ce n'est pas une science universelle de validité +d'image. Modifier une valeur ou la correspondance GOOD/SUSPECT/REJECT exige une nouvelle +version de politique/metrics et invalide les fingerprints ou caches qui incluent cette +politique ; des lignes v1 existantes ne doivent jamais être réinterprétées silencieusement. +La recommandation et l'override restent révisables et non destructifs. + +Project DB v21 additively stores an immutable typed request and results keyed by the +canonical campaign-plan `group_id` in 1..N. `next_group_id` is that same one-based identity: +it starts at 1, advances from completed group `k` to `k+1`, and equals `N+1` at completion. +Only the executor's private `group_index = group_id - 1` is zero-based. Result plus cursor is +atomic and precedes generic Task checkpoint progress. The +Governor estimate charges actual retained Task context capacity plus a 20 MiB one-group +analysis allowance covering the 1024x1024 grayscale raster, `CV_32F` normalized/Laplacian/ +gradient buffers, masks, and allocator margin. Decode and analysis buffers belong to one +group and are promptly released. Recovery temporarily uses the existing bounded maximum +codec and source arrays before Queue admission, then releases them; they do not live through +the admitted execution. Execution reuses +Task/Queue/Resource Governor and `sequence_break`; no parallel runtime is introduced. diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 0b48eea..dfcecbe 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -1,5 +1,17 @@ # Base de données projet Lardon3D +## Photo Quality Triage — Project DB v21 + +**PASS / FROZEN.** La migration additive v20→v21 ajoute +`photo_quality_triage_tasks` et `photo_quality_triage_results` sans modifier les tables +v20 ni les identités Capture/Asset/image. Chaque résultat conserve le `group_id` canonique +du plan (1..N). `next_group_id` utilise la même identité à base 1 : valeur initiale 1, +avancement de `k` vers `k+1` après publication du résultat `k`, et valeur terminale `N+1`. +Un éventuel `group_index = group_id - 1` reste strictement privé à l'exécuteur. Résultat et +curseur sont atomiques avant le checkpoint Task générique. Les lectures valident les types, +signes, bornes et relations dans les valeurs SQLite 64 bits avant toute conversion vers +les champs C étroits. Recommandation mesurée et override humain restent distincts. + ## Capture / Asset Provenance v1 — Project DB v19 (historique FROZEN) **PASS / FROZEN.** La migration transactionnelle v18→v19 diff --git a/docs/architecture/task_system.md b/docs/architecture/task_system.md index db5af70..0dbbe8b 100644 --- a/docs/architecture/task_system.md +++ b/docs/architecture/task_system.md @@ -131,6 +131,16 @@ appelle `sequence_break` avant le groupe suivant. Sa reprise passe par la Queue et le Resource Governor existants ; aucune boucle d'exécution parallèle n'est introduite. +**PASS / FROZEN** — le callback `photo_quality.triage` v1 réutilise ce +même Task/Queue/Resource Governor et une `sequence_break` entre groupes. Sa requête typée +est immuable ; le `next_group_id` canonique commence à 1, avance à `k+1` après le résultat +`k`, puis vaut `N+1` à terminaison. Résultat et curseur sont durables avant le checkpoint +générique, de sorte qu'une reprise ne devine ni ne réanalyse une identité déjà publiée. La +réservation charge le contexte retenu et 20 MiB de travail par groupe ; le +JPEG au-dessus de la limite opérationnelle de décodage 8192 pixels reste en attente +`UNAVAILABLE + SUSPECT` (ni erreur de décodage ni rejet) ; une entrée admise est réduite à +1024 pixels maximum avant analyse. Cette borne ne limite ni la campagne ni le dataset. + Le chemin de production de l'import ne possède plus de thread ni de drapeau d'annulation privés. Son wrapper TUI ne fait qu'enqueue/cancel/observer la tâche générique. Chaque callback traite un lot borné, checkpoint hors mutex de diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 13397bd..18d4fdf 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -121,13 +121,57 @@ Queue/Governor, persistance tâche/groupe→Capture et reprise sans duplication. La capacité bornée des propositions de revue conserve un préfixe déterministe; elle ne limite jamais l'évaluation complète ni le groupement scientifique. -## NEXT REAL-DATA MILESTONE — SCIENTIFIC EXECUTION ON ENGINE BAY INPUTS +## PHOTO QUALITY TRIAGE / ACQUISITION SELECTION — PASS / FROZEN -1. exécuter features, candidats, matching, vérification, tracks et Sparse SfM ; -2. évaluer la qualité de la reconstruction sparse multi-campagne ; -3. exécuter MVS/dense avec budgets et scratch contrôlés ; -4. publier durablement nuage dense et mesh ; -5. comparer le résultat aux tentatives photogrammétriques antérieures. +L'étape qualité canonique implémentée se place après la découverte bornée, les +métadonnées et l'association minimale des sources, mais avant la matérialisation +normale d'une campagne et avant toute représentation scientifique, feature, +matching, SfM ou MVS : + +```text +série source explicite +→ découverte bornée / métadonnées +→ candidats d'acquisition et confirmations existantes +→ Photo Quality Triage +→ recommandation GOOD / SUSPECT / REJECT par acquisition physique +→ sélection ou override humain explicite +→ création/exécution durable de campagne pour les groupes retenus +→ représentations scientifiques +→ features → candidats → matching → vérification → tracks → Sparse SfM +``` + +Le triage consomme les groupes d'acquisition existants et ne redéfinit jamais +Capture, Asset, `image_id`, SHA-256, chemin ou basename. Une paire A6000 +RAW+JPEG confirmée est donc une seule unité de triage ; le JPEG caméra valide +est le proxy rapide préféré et l'analyse ne développe pas les RAW à grande +échelle. Un JPEG S21 singleton est la représentation d'analyse naturelle. Le +résultat est non destructif et explicable : `GOOD` est sélectionné par défaut, +`SUSPECT` attend une acceptation explicite et `REJECT` est exclu par défaut mais +peut être forcé par l'humain. Ces états sont une recommandation et une sélection +opérationnelle, jamais une identité ni une suppression de source. + +L'implémentation reste déterministe, générique aux appareils, bornée en mémoire +et I/O, et exécutée par les Task, Queue et Resource Governor existants. Le décodage +JPEG applique une limite opérationnelle de 8192 pixels sur le plus grand axe, une +réduction à 1024 pixels et une réservation incluant le contexte retenu plus 20 MiB +de buffers d'analyse par groupe ; ces bornes ne sont pas des limites scientifiques +de campagne ou de dataset. +Elle doit conserver une voie honnête pour les RAW sans proxy plutôt que de +présumer que chaque RAW a un JPEG sibling. La revue TUI détaillée, la guidance +de capture et les keyframes vidéo restent des intégrations ultérieures qui +réutiliseront ce même chemin de qualité, sans second pipeline. + +## NEXT REAL-DATA MILESTONE — SELECTED SCIENTIFIC EXECUTION ON ENGINE BAY INPUTS + +Après validation de Photo Quality Triage / Acquisition Selection, l'intégration +scientifique porte uniquement sur les acquisitions sélectionnées : + +1. matérialiser leurs représentations scientifiques ; +2. exécuter features, candidats, matching, vérification, tracks et Sparse SfM ; +3. évaluer la qualité de la reconstruction sparse multi-campagne ; +4. exécuter MVS/dense avec budgets et scratch contrôlés ; +5. publier durablement nuage dense et mesh ; +6. comparer le résultat aux tentatives photogrammétriques antérieures. Ce milestone est une intégration réelle, pas une nouvelle série de micro-gates S3. Les antécédents d'OOM/SIGSEGV OpenMVS, pression swap, grands intermédiaires diff --git a/include/lardon3d/photo_quality.h b/include/lardon3d/photo_quality.h new file mode 100644 index 0000000..c47c679 --- /dev/null +++ b/include/lardon3d/photo_quality.h @@ -0,0 +1,86 @@ +#ifndef LARDON3D_PHOTO_QUALITY_H +#define LARDON3D_PHOTO_QUALITY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + LARDON3D_PHOTO_QUALITY_METRICS_VERSION = 1, + LARDON3D_PHOTO_QUALITY_REASON_CAPACITY = 256, + /* Metrics v1 retains at most a 1024-pixel longest edge. This engineering + * policy bounds deterministic per-group memory; it is not image validity. */ + LARDON3D_PHOTO_QUALITY_ANALYSIS_MAX_DIMENSION = 1024, + /* A valid JPEG above this 8192-pixel proxy edge is operationally unavailable + * before allocation. It remains SUSPECT/pending rather than a decode error. */ + LARDON3D_PHOTO_QUALITY_JPEG_MAX_DIMENSION = 8192, +}; + +typedef enum { + LARDON3D_PHOTO_QUALITY_METRIC_OK = 0, + LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE, + LARDON3D_PHOTO_QUALITY_METRIC_INVALID_INPUT, + LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR +} Lardon3DPhotoQualityMetricStatus; + +typedef enum { + LARDON3D_PHOTO_QUALITY_GOOD = 1, + LARDON3D_PHOTO_QUALITY_SUSPECT = 2, + LARDON3D_PHOTO_QUALITY_REJECT = 3 +} Lardon3DPhotoQualityRecommendation; + +typedef enum { + LARDON3D_PHOTO_QUALITY_OVERRIDE_NONE = 0, + LARDON3D_PHOTO_QUALITY_OVERRIDE_INCLUDE = 1, + LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE = 2 +} Lardon3DPhotoQualityOverride; + +typedef struct { + uint32_t metrics_version; + Lardon3DPhotoQualityMetricStatus status; + Lardon3DPhotoQualityRecommendation recommendation; + uint32_t decoded_width; + uint32_t decoded_height; + uint32_t analysis_width; + uint32_t analysis_height; + double sharpness_raw; + double sharpness_normalized; + double clipped_black_fraction; + double clipped_white_fraction; + double contrast_raw; + double contrast_normalized; + double low_texture_fraction; + char reasons[LARDON3D_PHOTO_QUALITY_REASON_CAPACITY]; +} Lardon3DPhotoQualityMetrics; + +/* Analyze one JPEG proxy/source using one bounded reduced-resolution grayscale + * decode. JPEG dimensions above the operational 8192-pixel decode ceiling + * return UNAVAILABLE + SUSPECT without allocating the raster; this is pending + * selection evidence, not a decode error or scientific rejection. Accepted + * images are decoded at an OpenCV JPEG reduction selected to retain at most a + * 1024-pixel maximum dimension. output is required, caller-owned, and always + * initialized when provided, including on invalid input and decode failure. + * path must name a readable JPEG. + * No Capture, + * Asset, image_id, SHA-256, path, or basename identity is inferred or changed. */ +Lardon3DPhotoQualityMetricStatus lardon3d_photo_quality_analyze_jpeg( + const char *path, Lardon3DPhotoQualityMetrics *output); + +/* Produce the required explicit result for a group that has no decodable JPEG + * proxy. RAW bytes are not developed merely for triage. */ +void lardon3d_photo_quality_raw_only(Lardon3DPhotoQualityMetrics *output); + +/* Default inclusion is recommendation-derived (GOOD only); a human override + * remains a separate durable decision and never rewrites measured metrics. */ +int lardon3d_photo_quality_effective_include( + Lardon3DPhotoQualityRecommendation recommendation, + Lardon3DPhotoQualityOverride override_value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/lardon3d/photo_quality_task.h b/include/lardon3d/photo_quality_task.h new file mode 100644 index 0000000..a9e2fea --- /dev/null +++ b/include/lardon3d/photo_quality_task.h @@ -0,0 +1,69 @@ +#ifndef LARDON3D_PHOTO_QUALITY_TASK_H +#define LARDON3D_PHOTO_QUALITY_TASK_H + +#include +#include +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LARDON3D_PHOTO_QUALITY_TASK_KIND "photo_quality.triage" +enum { LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION = 1, + LARDON3D_PHOTO_QUALITY_REQUEST_VERSION = 1 }; + +/* The durable request stores discovered acquisition evidence, never Capture, + * Asset, or image identities. Grouping is reproducible before materialization + * without guessing scientific identity. */ +typedef struct { + const Lardon3DAcquisitionCampaignSource *sources; + size_t source_count; + const Lardon3DAcquisitionCampaignConfirmation *confirmations; + size_t confirmation_count; +} Lardon3DPhotoQualityTaskRequest; + +#define LARDON3D_PHOTO_QUALITY_TASK_REQUEST_MAX_BYTES \ + LARDON3D_ACQUISITION_CAMPAIGN_TASK_REQUEST_MAX_BYTES + +/* Create one durable, initially unqueued triage Task for a valid existing + * ScanSet. request arrays are borrowed only for the call and copied into the + * bounded deterministic codec; task_id is required and receives the durable + * identity on success. The caller owns the returned Task. */ +Lardon3DTask *lardon3d_project_create_photo_quality_task( + Lardon3DAppState *state, uint64_t scanset_id, + const Lardon3DPhotoQualityTaskRequest *request, uint64_t *task_id); +/* Create and transfer the durable Task to state's Queue. Inputs follow the + * create contract; success transfers Task ownership to the Queue. */ +bool lardon3d_project_enqueue_photo_quality( + Lardon3DAppState *state, uint64_t scanset_id, + const Lardon3DPhotoQualityTaskRequest *request, uint64_t *task_id); +/* Rebuild bounded callback context from an immutable durable request. snapshot, + * reconstruction context, and binding are required and caller-owned; temporary + * maximum-capacity codec arrays are released before Queue admission. */ +bool lardon3d_photo_quality_task_reconstruct( + const Lardon3DTaskDurableSnapshot *snapshot, void *context, + Lardon3DTaskKindBinding *binding); +/* Encode the request deterministically using the acquisition-campaign v1 wire + * format. output may be NULL only for a size probe; size receives required or + * written bytes. No input storage is retained. */ +bool lardon3d_photo_quality_request_encode( + const Lardon3DPhotoQualityTaskRequest *request, unsigned char *output, + size_t capacity, size_t *size); +/* Strictly decode a bounded immutable request into caller-owned source and + * confirmation arrays. Their capacities must cover encoded counts; request + * borrows those arrays for their caller-controlled lifetime. */ +bool lardon3d_photo_quality_request_decode( + const unsigned char *input, size_t size, + Lardon3DAcquisitionCampaignSource *sources, size_t source_capacity, + Lardon3DAcquisitionCampaignConfirmation *confirmations, + size_t confirmation_capacity, Lardon3DPhotoQualityTaskRequest *request); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/include/lardon3d/project.h b/include/lardon3d/project.h index d20a4e2..3cd6d90 100644 --- a/include/lardon3d/project.h +++ b/include/lardon3d/project.h @@ -61,6 +61,12 @@ lardon3d_project_checkpoint_image_import_task(Lardon3DAppState *state, const Lar Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_acquisition_campaign_task( Lardon3DAppState *state, const Lardon3DTask *task, const Lardon3DProjectDbAcquisitionCampaignTask *parameters); +/* Persist a durable photo-quality Task snapshot, immutable typed request and durable cursor. + * state, task and parameters are required; OK means the generic checkpoint and v21 typed + * record are durable, while PUBLISHED_NOT_DURABLE leaves post-crash durability unconfirmed. */ +Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_photo_quality_task( + Lardon3DAppState *state, const Lardon3DTask *task, + const Lardon3DProjectDbPhotoQualityTask *parameters); Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_feature_extract_task( Lardon3DAppState *state, const Lardon3DTask *task, const Lardon3DProjectDbFeatureExtractTask *parameters); diff --git a/include/lardon3d/project_db.h b/include/lardon3d/project_db.h index a66bd5a..94d19df 100644 --- a/include/lardon3d/project_db.h +++ b/include/lardon3d/project_db.h @@ -7,10 +7,11 @@ #include #include +#include #include enum { - LARDON3D_PROJECT_DB_SCHEMA_VERSION = 20, + LARDON3D_PROJECT_DB_SCHEMA_VERSION = 21, LARDON3D_PROJECT_DB_ID_CAPACITY = 65, LARDON3D_PROJECT_DB_KIND_CAPACITY = 65, LARDON3D_PROJECT_DB_PATH_CAPACITY = 4096, @@ -114,6 +115,25 @@ typedef struct { uint64_t capture_id; } Lardon3DProjectDbAcquisitionCampaignCapture; +typedef struct { + uint64_t task_id; + uint64_t scanset_id; + /* Canonical one-based group ID of the next work item. Valid durable values + * are 1..group_count+1, where group_count+1 denotes completion. */ + uint32_t next_group_id; + uint32_t group_count; + const unsigned char *request; + size_t request_size; +} Lardon3DProjectDbPhotoQualityTask; + +typedef struct { + uint64_t task_id; + uint32_t group_id; + uint32_t proxy_source_index; + Lardon3DPhotoQualityMetrics metrics; + Lardon3DPhotoQualityOverride override_value; +} Lardon3DProjectDbPhotoQualityResult; + typedef struct { uint64_t scanset_id; char name[LARDON3D_PROJECT_DB_SCANSET_NAME_CAPACITY]; @@ -598,6 +618,40 @@ Lardon3DProjectDbResult lardon3d_project_db_retain_acquisition_campaign_capture( Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_capture( Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, Lardon3DProjectDbAcquisitionCampaignCapture *capture); +/* Atomically record generic Task state and the immutable typed request for an + * existing ScanSet. All pointers are required except checkpoint; request bytes + * are borrowed only for the call. The request identifies campaign groups + * operationally and never creates or infers Capture/Asset/image identities. */ +Lardon3DProjectDbResult lardon3d_project_db_record_photo_quality_task( + Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, + const char *task_kind, uint32_t task_kind_version, + const Lardon3DProjectDbCheckpoint *checkpoint, + const Lardon3DProjectDbPhotoQualityTask *parameters, int64_t updated_at); +/* On success, parameters receives validated one-based cursor/count fields and + * borrows caller-owned request storage containing the immutable request bytes. + * Insufficient capacity is reported as corrupt durable state, never truncated. */ +Lardon3DProjectDbResult lardon3d_project_db_load_photo_quality_task( + Lardon3DProjectDb *database, uint64_t task_id, unsigned char *request, + size_t request_capacity, Lardon3DProjectDbPhotoQualityTask *parameters); +/* Result publication and next_group_id advance are atomic. result->group_id is + * the canonical plan ID in 1..N and must equal the task's current one-based + * cursor; next_group_id must equal result->group_id+1 (N+1 after the last + * group). Recovery must never checkpoint generic Task progress beyond a group + * whose result is not durable. Retry converges through the retained result and + * advanced cursor; neither ID is Capture, Asset, or image identity. */ +Lardon3DProjectDbResult lardon3d_project_db_record_photo_quality_result( + Lardon3DProjectDb *database, const Lardon3DProjectDbPhotoQualityResult *result, + uint32_t next_group_id); +/* Load one canonical group result into required caller-owned storage. SQLite + * integer/enum/range/status relations are validated before public narrowing. */ +Lardon3DProjectDbResult lardon3d_project_db_load_photo_quality_result( + Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, + Lardon3DProjectDbPhotoQualityResult *result); +/* Human override is deliberately separate from immutable measured metrics. + * group_id is canonical and one-based; retrying the same value is idempotent. */ +Lardon3DProjectDbResult lardon3d_project_db_set_photo_quality_override( + Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, + Lardon3DPhotoQualityOverride override_value); Lardon3DProjectDbResult lardon3d_project_db_allocate_task_id(Lardon3DProjectDb *database, uint64_t *task_id); Lardon3DProjectDbResult lardon3d_project_db_load_task(Lardon3DProjectDb *database, uint64_t task_id, diff --git a/meson.build b/meson.build index fbd2a4e..38f328c 100644 --- a/meson.build +++ b/meson.build @@ -153,6 +153,8 @@ executable( 'src/acquisition_ingest.cpp', 'src/acquisition_campaign.cpp', 'src/acquisition_campaign_task.cpp', + 'src/photo_quality.cpp', + 'src/photo_quality_task.cpp', 'src/feature_extractor_opencv.cpp', 'src/feature_store.c', 'src/feature_extractor_opencv.cpp', @@ -197,6 +199,7 @@ executable( '-DLARDON3D_SPARSE_SFM_TASK_AVAILABLE', '-DLARDON3D_INCREMENTAL_RECONSTRUCTION_TASK_AVAILABLE', '-DLARDON3D_ACQUISITION_CAMPAIGN_TASK_AVAILABLE', + '-DLARDON3D_PHOTO_QUALITY_TASK_AVAILABLE', ], dependencies: [ ncursesw, @@ -397,6 +400,68 @@ acquisition_campaign_task_test = executable( ) test('acquisition-campaign-task', acquisition_campaign_task_test, timeout: 30) +photo_quality_test = executable( + 'test-photo-quality', + sources: ['tests/test_photo_quality.cpp', 'src/photo_quality.cpp'], + include_directories: include_directories('include'), + dependencies: [opencv], +) +test('photo-quality', photo_quality_test, timeout: 30) + +photo_quality_task_test = executable( + 'test-photo-quality-task', + sources: [ + 'tests/test_photo_quality_task.cpp', + 'src/photo_quality_task.cpp', 'src/photo_quality.cpp', + 'src/acquisition_campaign.cpp', 'src/acquisition_pairing.cpp', + 'src/acquisition_campaign_task.cpp', 'src/acquisition_ingest.cpp', + 'src/raw_development.cpp', 'src/app_state.c', + 'src/image_catalog_persistent.c', 'src/image_catalog.c', 'src/image_view.c', + 'src/project.c', 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', + 'src/task_queue.c', 'src/resource_governor.c', + 'src/resource_snapshot.c', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv, libraw, libexif, libpng, libdeflate], +) +test('photo-quality-task', photo_quality_task_test, timeout: 30) + +photo_quality_real_scan = executable( + 'photo-quality-real-scan', + sources: [ + 'tests/photo_quality_real_scan.cpp', + 'src/photo_quality_task.cpp', 'src/photo_quality.cpp', + 'src/acquisition_campaign.cpp', 'src/acquisition_pairing.cpp', + 'src/acquisition_campaign_task.cpp', 'src/acquisition_ingest.cpp', + 'src/raw_development.cpp', 'src/app_state.c', + 'src/image_catalog_persistent.c', 'src/image_catalog.c', 'src/image_view.c', + 'src/project.c', 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', + 'src/task_queue.c', 'src/resource_governor.c', 'src/resource_snapshot.c', + 'src/hardware_profile.c', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv, libraw, libexif, libpng, libdeflate], +) + +photo_quality_restart_scan = executable( + 'photo-quality-restart-scan', + sources: [ + 'tests/photo_quality_restart_scan.cpp', + 'src/photo_quality_task.cpp', 'src/photo_quality.cpp', + 'src/acquisition_campaign.cpp', 'src/acquisition_pairing.cpp', + 'src/acquisition_campaign_task.cpp', 'src/acquisition_ingest.cpp', + 'src/raw_development.cpp', 'src/app_state.c', + 'src/image_catalog_persistent.c', 'src/image_catalog.c', 'src/image_view.c', + 'src/project.c', 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', + 'src/task_queue.c', 'src/resource_governor.c', 'src/resource_snapshot.c', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv, libraw, libexif, libpng, libdeflate], +) + feature_store_test = executable( 'test-feature-store', sources: [ diff --git a/scan3d/tri_photos.py b/scan3d/tri_photos.py index 4552931..25a337b 100644 --- a/scan3d/tri_photos.py +++ b/scan3d/tri_photos.py @@ -129,6 +129,11 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("images", type=Path) parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--compatibility-links", + action="store_true", + help="crée des vues par liens symboliques sans supprimer de contenu existant", + ) args = parser.parse_args() images_dir = args.images.expanduser().resolve() @@ -172,16 +177,22 @@ def main() -> int: print() output_dir.mkdir(parents=True, exist_ok=True) - for category in ("bonnes", "suspectes", "mauvaises"): - category_dir = output_dir / category - if category_dir.exists(): - for child in category_dir.iterdir(): - if child.is_symlink() or child.is_file(): - child.unlink() - category_dir.mkdir(parents=True, exist_ok=True) + report_path = output_dir / "resultats.csv" + if report_path.exists(): + print( + f"Rapport déjà présent, aucun fichier modifié : {report_path}", + file=sys.stderr, + ) + return 2 - with (output_dir / "resultats.csv").open( - "w", encoding="utf-8", newline="" + if args.compatibility_links: + for category in ("bonnes", "suspectes", "mauvaises"): + (output_dir / category).mkdir(parents=True, exist_ok=True) + + # Exclusive creation makes the analysis/report default non-destructive even + # when two invocations target the same output directory. + with report_path.open( + "x", encoding="utf-8", newline="" ) as csv_file: writer = csv.writer(csv_file) writer.writerow( @@ -199,9 +210,17 @@ def main() -> int: counters = {"bonnes": 0, "suspectes": 0, "mauvaises": 0} for result in results: counters[result.category] += 1 - link_name = f"{counters[result.category]:06d}_{result.path.name}" - link_path = output_dir / result.category / link_name - link_path.symlink_to(result.path.resolve()) + if args.compatibility_links: + link_name = f"{counters[result.category]:06d}_{result.path.name}" + link_path = output_dir / result.category / link_name + if link_path.exists() or link_path.is_symlink(): + print( + f"Lien existant conservé : {link_path}", file=sys.stderr + ) + else: + # Compatibility output references originals; it never moves + # or deletes immutable source files. + link_path.symlink_to(result.path.resolve()) writer.writerow( [ diff --git a/src/photo_quality.cpp b/src/photo_quality.cpp new file mode 100644 index 0000000..b69adda --- /dev/null +++ b/src/photo_quality.cpp @@ -0,0 +1,327 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +/* Metrics v1 is a deterministic engineering recommendation policy calibrated + * with synthetic fixtures and real Sony A6000 / Samsung S21 captures. These + * cutoffs are not universal scientific validity criteria. Any value or mapping + * change requires a new metrics/policy version and corresponding cache or + * fingerprint invalidation rather than silently reinterpreting stored rows. */ +constexpr double kSharpnessVeryLow = 0.00018; // REJECT: severe normalized blur. +constexpr double kSharpnessLow = 0.00045; // SUSPECT: review normalized blur. +constexpr unsigned char kBlackSampleMaximum = 5; // Prototype-compatible black sample. +constexpr unsigned char kWhiteSampleMinimum = 250; // Prototype-compatible white sample. +constexpr double kWhiteClippingSuspect = 0.08; // SUSPECT above 8% white samples. +constexpr double kWhiteClippingSevere = 0.20; // REJECT above 20% white samples. +constexpr double kBlackClippingSuspect = 0.15; // SUSPECT above 15% black samples. +constexpr double kBlackClippingSevere = 0.35; // REJECT above 35% black samples. +constexpr double kContrastLow = 0.025; // SUSPECT below normalized standard deviation. +constexpr double kLowTextureFraction = 0.985; // SUSPECT above low-gradient fraction. +constexpr double kSobelLowTextureMagnitude = 0.035; // Low-gradient normalized cutoff. +/* Structural validation is deliberately bounded independently of JPEG pixel + * dimensions. This is parser resource admission, not a scientific image-size + * limit: every byte read or skipped consumes the same finite work budget. */ +constexpr uint64_t kJpegStructuralByteLimit = 64ULL * 1024ULL * 1024ULL; + +bool jpeg_dimensions(const char *path, uint32_t &width, uint32_t &height) { + std::FILE *file = std::fopen(path, "rb"); + if (!file) + return false; + const auto close = [&file]() { std::fclose(file); }; + uint64_t structural_bytes = 0; + const auto read_byte = [&file, &structural_bytes]() { + if (structural_bytes == kJpegStructuralByteLimit) + return EOF; + const int value = std::fgetc(file); + if (value != EOF) + ++structural_bytes; + return value; + }; + const auto read_exact = [&file, &structural_bytes](void *destination, size_t count) { + if (count > kJpegStructuralByteLimit - structural_bytes) + return false; + const size_t actual = std::fread(destination, 1, count, file); + structural_bytes += actual; + return actual == count; + }; + const auto skip = [&file, &structural_bytes](uint64_t count) { + if (count > kJpegStructuralByteLimit - structural_bytes || count > LONG_MAX) + return false; + if (std::fseek(file, static_cast(count), SEEK_CUR) != 0) + return false; + structural_bytes += count; + return true; + }; + if (read_byte() != 0xff || read_byte() != 0xd8) { + close(); + return false; + } + bool have_dimensions = false; + bool in_entropy = false; + int marker = -1; + for (;;) { + if (marker < 0) { + int prefix = read_byte(); + if (in_entropy) { + while (prefix != EOF) { + if (prefix != 0xff) { + prefix = read_byte(); + continue; + } + marker = read_byte(); + while (marker == 0xff) + marker = read_byte(); + if (marker == 0x00 || (marker >= 0xd0 && marker <= 0xd7)) { + prefix = read_byte(); + continue; + } + break; + } + } else { + if (prefix != 0xff) { + close(); + return false; + } + marker = read_byte(); + } + } + while (marker == 0xff) + marker = read_byte(); + if (marker == EOF || marker == 0x00 || marker == 0xd8 || + (!in_entropy && marker >= 0xd0 && marker <= 0xd7)) { + close(); + return false; + } + in_entropy = false; + if (marker == 0xd9) { + const int trailing = read_byte(); + close(); + return have_dimensions && trailing == EOF; + } + if (marker == 0x01) { + marker = -1; + continue; + } + int high = read_byte(); + int low = read_byte(); + if (high == EOF || low == EOF) { + close(); + return false; + } + const unsigned length = (static_cast(high) << 8) | + static_cast(low); + if (length < 2) { + close(); + return false; + } + const bool sof = (marker >= 0xc0 && marker <= 0xcf && marker != 0xc4 && + marker != 0xc8 && marker != 0xcc); + if (sof) { + unsigned char header[6]; + if (length < 8 || !read_exact(header, sizeof(header))) { + close(); + return false; + } + height = (static_cast(header[1]) << 8) | header[2]; + width = (static_cast(header[3]) << 8) | header[4]; + if (width == 0 || height == 0 || have_dimensions || + !skip(length - 8)) { + close(); + return false; + } + have_dimensions = true; + } else if (!skip(length - 2)) { + close(); + return false; + } + if (marker == 0xda) { + if (!have_dimensions) { + close(); + return false; + } + in_entropy = true; + } + marker = -1; + if (std::ferror(file)) { + close(); + return false; + } + } +} + +int reduced_grayscale_flag(uint32_t maximum_dimension) { + if (maximum_dimension > 4u * LARDON3D_PHOTO_QUALITY_ANALYSIS_MAX_DIMENSION) + return cv::IMREAD_REDUCED_GRAYSCALE_8; + if (maximum_dimension > 2u * LARDON3D_PHOTO_QUALITY_ANALYSIS_MAX_DIMENSION) + return cv::IMREAD_REDUCED_GRAYSCALE_4; + if (maximum_dimension > LARDON3D_PHOTO_QUALITY_ANALYSIS_MAX_DIMENSION) + return cv::IMREAD_REDUCED_GRAYSCALE_2; + return cv::IMREAD_GRAYSCALE; +} + +void initialize(Lardon3DPhotoQualityMetrics *output) { + std::memset(output, 0, sizeof(*output)); + output->metrics_version = LARDON3D_PHOTO_QUALITY_METRICS_VERSION; + output->status = LARDON3D_PHOTO_QUALITY_METRIC_INVALID_INPUT; + output->recommendation = LARDON3D_PHOTO_QUALITY_REJECT; +} + +void append_reason(char reasons[LARDON3D_PHOTO_QUALITY_REASON_CAPACITY], const char *reason) { + const size_t used = std::strlen(reasons); + if (used >= LARDON3D_PHOTO_QUALITY_REASON_CAPACITY - 1) + return; + (void)std::snprintf(reasons + used, LARDON3D_PHOTO_QUALITY_REASON_CAPACITY - used, + "%s%s", used ? ";" : "", reason); +} + +} // namespace + +extern "C" Lardon3DPhotoQualityMetricStatus lardon3d_photo_quality_analyze_jpeg( + const char *path, Lardon3DPhotoQualityMetrics *output) { + if (!output) + return LARDON3D_PHOTO_QUALITY_METRIC_INVALID_INPUT; + initialize(output); + if (!path || !path[0]) + return output->status; + + try { + uint32_t source_width = 0; + uint32_t source_height = 0; + if (!jpeg_dimensions(path, source_width, source_height)) { + output->status = LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR; + append_reason(output->reasons, "JPEG_DECODE_ERROR"); + return output->status; + } + const uint32_t source_maximum = std::max(source_width, source_height); + output->decoded_width = source_width; + output->decoded_height = source_height; + if (source_maximum > LARDON3D_PHOTO_QUALITY_JPEG_MAX_DIMENSION) { + /* This is an operational admission ceiling, not malformed input and not + * a scientific rejection. Preserve a pending SUSPECT result so a proxy, + * policy change, or explicit human override can resolve selection. */ + output->status = LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE; + output->recommendation = LARDON3D_PHOTO_QUALITY_SUSPECT; + append_reason(output->reasons, "JPEG_DIMENSIONS_EXCEED_OPERATIONAL_LIMIT"); + return output->status; + } + /* Complete structural validation through EOI precedes allocation. This is + * what distinguishes an operationally oversized proxy from a truncated + * file that merely contains a syntactically sufficient oversized SOF. */ + cv::Mat decoded = cv::imread(path, reduced_grayscale_flag(source_maximum)); + if (decoded.empty() || decoded.cols <= 0 || decoded.rows <= 0) { + output->status = LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR; + append_reason(output->reasons, "JPEG_DECODE_ERROR"); + return output->status; + } + const double scale = static_cast(LARDON3D_PHOTO_QUALITY_ANALYSIS_MAX_DIMENSION) / + static_cast(std::max(decoded.cols, decoded.rows)); + const int width = std::max(1, static_cast(std::lround(decoded.cols * scale))); + const int height = std::max(1, static_cast(std::lround(decoded.rows * scale))); + cv::Mat analysis; + cv::resize(decoded, analysis, cv::Size(width, height), 0.0, 0.0, + scale < 1.0 ? cv::INTER_AREA : cv::INTER_NEAREST); + decoded.release(); + output->analysis_width = static_cast(analysis.cols); + output->analysis_height = static_cast(analysis.rows); + + cv::Mat normalized; + analysis.convertTo(normalized, CV_32F, 1.0 / 255.0); + cv::Scalar mean; + cv::Scalar deviation; + cv::meanStdDev(normalized, mean, deviation); + output->contrast_raw = deviation[0] * 255.0; + output->contrast_normalized = deviation[0]; + + cv::Mat laplacian; + cv::Laplacian(normalized, laplacian, CV_32F, 3); + cv::Scalar lap_mean; + cv::Scalar lap_deviation; + cv::meanStdDev(laplacian, lap_mean, lap_deviation); + output->sharpness_raw = lap_deviation[0] * lap_deviation[0] * 255.0 * 255.0; + output->sharpness_normalized = lap_deviation[0] * lap_deviation[0]; + laplacian.release(); + + cv::Mat gradient_x; + cv::Mat gradient_y; + cv::Sobel(normalized, gradient_x, CV_32F, 1, 0, 3); + cv::Sobel(normalized, gradient_y, CV_32F, 0, 1, 3); + cv::Mat magnitude; + cv::magnitude(gradient_x, gradient_y, magnitude); + output->low_texture_fraction = + static_cast(cv::countNonZero(magnitude < kSobelLowTextureMagnitude)) / + static_cast(magnitude.total()); + output->clipped_black_fraction = + static_cast(cv::countNonZero(analysis <= kBlackSampleMaximum)) / + static_cast(analysis.total()); + output->clipped_white_fraction = + static_cast(cv::countNonZero(analysis >= kWhiteSampleMinimum)) / + static_cast(analysis.total()); + + bool severe = false; + bool suspect = false; + if (output->sharpness_normalized < kSharpnessVeryLow) { + append_reason(output->reasons, "SHARPNESS_VERY_LOW"); + severe = true; + } else if (output->sharpness_normalized < kSharpnessLow) { + append_reason(output->reasons, "SHARPNESS_LOW"); + suspect = true; + } + if (output->clipped_white_fraction > kWhiteClippingSevere || + output->clipped_black_fraction > kBlackClippingSevere) { + append_reason(output->reasons, "EXPOSURE_CLIPPING_SEVERE"); + severe = true; + } else if (output->clipped_white_fraction > kWhiteClippingSuspect || + output->clipped_black_fraction > kBlackClippingSuspect) { + append_reason(output->reasons, "EXPOSURE_CLIPPING"); + suspect = true; + } + if (output->contrast_normalized < kContrastLow || + output->low_texture_fraction > kLowTextureFraction) { + append_reason(output->reasons, "LOW_TEXTURE_OR_CONTRAST"); + suspect = true; + } + output->status = LARDON3D_PHOTO_QUALITY_METRIC_OK; + output->recommendation = severe ? LARDON3D_PHOTO_QUALITY_REJECT + : suspect ? LARDON3D_PHOTO_QUALITY_SUSPECT + : LARDON3D_PHOTO_QUALITY_GOOD; + if (!output->reasons[0]) + append_reason(output->reasons, "OK"); + return output->status; + } catch (...) { + // C++ and OpenCV exceptions are contained at the public C17 ABI boundary. + initialize(output); + output->status = LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR; + append_reason(output->reasons, "JPEG_DECODE_ERROR"); + return output->status; + } +} + +extern "C" void lardon3d_photo_quality_raw_only(Lardon3DPhotoQualityMetrics *output) { + if (!output) + return; + initialize(output); + output->status = LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE; + output->recommendation = LARDON3D_PHOTO_QUALITY_SUSPECT; + append_reason(output->reasons, "METRIC_UNAVAILABLE_REQUIRES_JPEG_PROXY"); +} + +extern "C" int lardon3d_photo_quality_effective_include( + Lardon3DPhotoQualityRecommendation recommendation, + Lardon3DPhotoQualityOverride override_value) { + if (override_value == LARDON3D_PHOTO_QUALITY_OVERRIDE_INCLUDE) + return 1; + if (override_value == LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE) + return 0; + return recommendation == LARDON3D_PHOTO_QUALITY_GOOD ? 1 : 0; +} diff --git a/src/photo_quality_task.cpp b/src/photo_quality_task.cpp new file mode 100644 index 0000000..5b62259 --- /dev/null +++ b/src/photo_quality_task.cpp @@ -0,0 +1,255 @@ +extern "C" { +#include +#include +#include +#include +} + +#include +#include +#include +#include + +namespace { +// One admitted group owns this Metrics v1 operational working allowance. It +// covers the bounded 1024-edge raster and float intermediates, not dataset size. +constexpr uint64_t kAnalysisWorkingBytes = UINT64_C(20) * 1024u * 1024u; + +struct Context { + char project_path[LARDON3D_APP_STATE_PATH_CAPACITY]{}; + Lardon3DProjectDb *db{}; + Lardon3DResourceGovernor *governor{}; + uint64_t scanset{}; + std::vector sources; + std::vector confirmations; + std::vector encoded; + Lardon3DAcquisitionCampaignPlan plan{}; +}; + +uint64_t context_owned_bytes(const Context &context) { + // The Context and its vector storage live for the complete Task lifetime. + // Charging their actual retained capacities plus the exact one-group analysis + // allowance makes each Governor admission truthful without imposing a global + // source-count or scientific dataset-size limit. + return sizeof(Context) + + context.sources.capacity() * sizeof(Lardon3DAcquisitionCampaignSource) + + context.confirmations.capacity() * + sizeof(Lardon3DAcquisitionCampaignConfirmation) + + context.encoded.capacity(); +} + +void destroy(void *value) { delete static_cast(value); } + +void runtime(Context *context, Lardon3DAppState &state) { + lardon3d_app_state_init(&state); + state.project_loaded = true; + state.project_db = context->db; + state.resource_governor = context->governor; + std::snprintf(state.project_path, sizeof(state.project_path), "%s", context->project_path); +} + +bool checkpoint(Context *context, Lardon3DTask *task, uint32_t next_group_id) { + Lardon3DAppState state; + runtime(context, state); + Lardon3DProjectDbPhotoQualityTask parameters{ + lardon3d_task_id(task), context->scanset, next_group_id, + static_cast(context->plan.group_count), context->encoded.data(), + context->encoded.size()}; + return lardon3d_project_checkpoint_photo_quality_task(&state, task, ¶meters) == + LARDON3D_PROJECT_TASK_CHECKPOINT_OK; +} + +bool run_impl(Lardon3DTask *task, void *value) { + auto *context = static_cast(value); + Lardon3DProjectDbPhotoQualityTask persisted{}; + if (lardon3d_project_db_load_photo_quality_task(context->db, lardon3d_task_id(task), + context->encoded.data(), context->encoded.size(), &persisted) != LARDON3D_PROJECT_DB_OK) + return lardon3d_task_fail(task, "Triage photo durable introuvable."); + + for (uint32_t group_id = persisted.next_group_id; group_id <= context->plan.group_count; + ++group_id) { + if (!lardon3d_task_checkpoint(task)) return false; + /* Durable group_id is the canonical one-based acquisition-plan identity. + * Only this private index converts it to vector position; neither value is + * scientific Capture identity. */ + const uint32_t group_index = group_id - 1u; + const auto &group = context->plan.groups[group_index]; + if (group.group_id != group_id) + return lardon3d_task_fail(task, "Identité de groupe de triage incohérente."); + uint32_t proxy = UINT32_MAX; + for (size_t i = 0; i < group.source_count; ++i) { + size_t source_index = group.source_indices[i]; + if (context->sources[source_index].source_kind == LARDON3D_ACQUISITION_SOURCE_JPEG) { + proxy = static_cast(source_index); + break; + } + } + Lardon3DProjectDbPhotoQualityResult result{}; + result.task_id = lardon3d_task_id(task); + result.group_id = group_id; + result.proxy_source_index = proxy; + result.override_value = LARDON3D_PHOTO_QUALITY_OVERRIDE_NONE; + if (proxy == UINT32_MAX) + lardon3d_photo_quality_raw_only(&result.metrics); + else + (void)lardon3d_photo_quality_analyze_jpeg(context->sources[proxy].path, &result.metrics); + + /* Publish measurement and the next canonical group ID in one transaction. + * Generic Task progress may lag after a crash, but can never lead a missing + * result; restart resumes at group_id+1 without guessing identity. */ + const uint32_t next_group_id = group_id + 1u; + if (lardon3d_project_db_record_photo_quality_result(context->db, &result, next_group_id) != + LARDON3D_PROJECT_DB_OK) + return lardon3d_task_fail(task, "Publication du résultat de triage impossible."); + unsigned progress = static_cast((uint64_t(group_id) * 100u) / + context->plan.group_count); + if (!lardon3d_task_set_progress(task, progress, "Groupe photo analysé.") || + !checkpoint(context, task, next_group_id)) + return lardon3d_task_fail(task, "Checkpoint du triage photo impossible."); + if (group_id < context->plan.group_count) { + /* One acquisition group owns one bounded admission. sequence_break releases + * it before pause/cancel handling and Governor re-admission for the next. */ + Lardon3DTaskExecutionContract contract{}; + Lardon3DResourceReservation *reservation = nullptr; + if (!lardon3d_task_sequence_break(task, context->governor, &reservation, &contract)) + return false; + } + } + return true; +} + +bool run(Lardon3DTask *task, void *value) noexcept { + try { return run_impl(task, value); } + catch (const std::bad_alloc &) { return lardon3d_task_fail(task, "Mémoire insuffisante."); } + catch (...) { return lardon3d_task_fail(task, "Erreur interne du triage photo."); } +} + +void finished(const Lardon3DTask *task, void *value) noexcept { + try { + Lardon3DAppState state; + runtime(static_cast(value), state); + (void)lardon3d_project_checkpoint_task(&state, task); + } catch (...) {} +} + +Context *make_context(const char *path, Lardon3DProjectDb *db, Lardon3DResourceGovernor *governor, + uint64_t scanset, const Lardon3DPhotoQualityTaskRequest &request, + const unsigned char *encoded, size_t encoded_size) { + std::unique_ptr context(new (std::nothrow) Context); + if (!context || !path || !path[0] || + std::snprintf(context->project_path, sizeof(context->project_path), "%s", path) >= + static_cast(sizeof(context->project_path))) return nullptr; + context->db = db; context->governor = governor; context->scanset = scanset; + context->sources.assign(request.sources, request.sources + request.source_count); + context->confirmations.assign(request.confirmations, + request.confirmations + request.confirmation_count); + context->encoded.assign(encoded, encoded + encoded_size); + if (lardon3d_acquisition_campaign_plan(context->sources.data(), context->sources.size(), + context->confirmations.data(), context->confirmations.size(), &context->plan) != + LARDON3D_ACQUISITION_CAMPAIGN_OK || context->plan.group_count == 0) return nullptr; + return context.release(); +} + +Lardon3DAcquisitionCampaignTaskRequest campaign_request(const Lardon3DPhotoQualityTaskRequest &r) { + Lardon3DAcquisitionCampaignTaskRequest wire{}; + wire.sources = r.sources; wire.source_count = r.source_count; + wire.confirmations = r.confirmations; wire.confirmation_count = r.confirmation_count; + wire.ingest_options.representation = LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE; + wire.ingest_options.max_source_bytes = 1; + return wire; +} +} + +extern "C" bool lardon3d_photo_quality_request_encode(const Lardon3DPhotoQualityTaskRequest *r, + unsigned char *out, size_t cap, size_t *size) { + if (!r) { if (size) *size = 0; return false; } + try { auto wire = campaign_request(*r); return lardon3d_acquisition_campaign_request_encode(&wire, out, cap, size); } + catch (...) { if (size) *size = 0; return false; } +} + +extern "C" bool lardon3d_photo_quality_request_decode(const unsigned char *input, size_t size, + Lardon3DAcquisitionCampaignSource *sources, size_t source_capacity, + Lardon3DAcquisitionCampaignConfirmation *confirmations, size_t confirmation_capacity, + Lardon3DPhotoQualityTaskRequest *request) { + if (!request) return false; + try { + Lardon3DAcquisitionCampaignTaskRequest wire{}; + if (!lardon3d_acquisition_campaign_request_decode(input, size, sources, source_capacity, + confirmations, confirmation_capacity, &wire)) return false; + request->sources = wire.sources; request->source_count = wire.source_count; + request->confirmations = wire.confirmations; request->confirmation_count = wire.confirmation_count; + return true; + } catch (...) { return false; } +} + +extern "C" bool lardon3d_photo_quality_task_reconstruct(const Lardon3DTaskDurableSnapshot *snapshot, + void *userdata, Lardon3DTaskKindBinding *binding) { + try { + auto *rt = static_cast(userdata); + if (!snapshot || !rt || !binding) return false; + /* Restoration happens before Queue admission. These temporary buffers are + * bounded by the durable codec/source capacities and are destroyed when + * reconstruction returns; only the compact Context survives admission. */ + std::vector blob(LARDON3D_PHOTO_QUALITY_TASK_REQUEST_MAX_BYTES); + Lardon3DProjectDbPhotoQualityTask persisted{}; + if (lardon3d_project_db_load_photo_quality_task(rt->project_db, snapshot->id, blob.data(), + blob.size(), &persisted) != LARDON3D_PROJECT_DB_OK) return false; + std::vector sources(LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES); + std::vector confirmations(LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES); + Lardon3DPhotoQualityTaskRequest request{}; + if (!lardon3d_photo_quality_request_decode(blob.data(), persisted.request_size, sources.data(), + sources.size(), confirmations.data(), confirmations.size(), &request)) return false; + auto *context = make_context(rt->project_path, rt->project_db, rt->resource_governor, + persisted.scanset_id, request, blob.data(), persisted.request_size); + if (!context) return false; + *binding = {run, context, destroy, finished, context}; + return true; + } catch (...) { return false; } +} + +extern "C" Lardon3DTask *lardon3d_project_create_photo_quality_task(Lardon3DAppState *state, + uint64_t scanset, const Lardon3DPhotoQualityTaskRequest *request, uint64_t *task_id) { + if (task_id) *task_id = 0; + try { + if (!state || !state->project_loaded || !state->project_db || !state->resource_governor || + !request || !task_id) return nullptr; + Lardon3DProjectDbScanSet row{}; + if (lardon3d_project_db_load_scanset(state->project_db, scanset, &row) != LARDON3D_PROJECT_DB_OK) return nullptr; + size_t size = 0; + if (!lardon3d_photo_quality_request_encode(request, nullptr, 0, &size)) return nullptr; + std::vector blob(size); + if (!lardon3d_photo_quality_request_encode(request, blob.data(), blob.size(), &size)) return nullptr; + auto *context = make_context(state->project_path, state->project_db, state->resource_governor, + scanset, *request, blob.data(), size); + if (!context) return nullptr; + uint64_t id = 0; + if (lardon3d_project_db_allocate_task_id(state->project_db, &id) != + LARDON3D_PROJECT_DB_OK) { + delete context; + return nullptr; + } + const uint64_t retained = context_owned_bytes(*context); + if (retained > UINT64_MAX - kAnalysisWorkingBytes) { delete context; return nullptr; } + Lardon3DResourceEstimate estimate{retained + kAnalysisWorkingBytes, 0, 0, 0, + 1, 1, 1, 0, 1, + LARDON3D_RESOURCE_TASK_IMPORT}; + auto *task = lardon3d_task_create_typed("Triage qualité photo", &estimate, + LARDON3D_PHOTO_QUALITY_TASK_KIND, 1, run, context, destroy); + if (!task || !lardon3d_task_assign_id(task, id) || + !lardon3d_task_set_finished_callback(task, finished, context) || !checkpoint(context, task, 1)) { + lardon3d_task_destroy(task); return nullptr; + } + *task_id = id; return task; + } catch (...) { return nullptr; } +} + +extern "C" bool lardon3d_project_enqueue_photo_quality(Lardon3DAppState *state, uint64_t scanset, + const Lardon3DPhotoQualityTaskRequest *request, uint64_t *task_id) { + try { + if (!state || !state->task_queue) return false; + auto *task = lardon3d_project_create_photo_quality_task(state, scanset, request, task_id); + if (!task) return false; + if (!lardon3d_task_queue_add(state->task_queue, task, nullptr)) { lardon3d_task_destroy(task); return false; } + return true; + } catch (...) { return false; } +} diff --git a/src/project.c b/src/project.c index 777deae..94bcc6d 100644 --- a/src/project.c +++ b/src/project.c @@ -675,7 +675,8 @@ checkpoint_task_internal(Lardon3DAppState *state, const Lardon3DTask *task, const Lardon3DProjectDbCandidatePairGenerateTask *candidate_parameters, const Lardon3DProjectDbMatcherTask *matcher_parameters, const Lardon3DProjectDbGeometricVerifierTask *geometric_parameters, - const Lardon3DProjectDbAcquisitionCampaignTask *campaign_parameters) { + const Lardon3DProjectDbAcquisitionCampaignTask *campaign_parameters, + const Lardon3DProjectDbPhotoQualityTask *quality_parameters) { if (!state || !state->project_loaded || !state->project_db) { return LARDON3D_PROJECT_TASK_CHECKPOINT_NO_PROJECT; } @@ -746,6 +747,10 @@ checkpoint_task_internal(Lardon3DAppState *state, const Lardon3DTask *task, ? lardon3d_project_db_record_acquisition_campaign_task( state->project_db, &snapshot, task_kind, task_kind_version, &checkpoint, campaign_parameters, now.tv_sec) + : quality_parameters + ? lardon3d_project_db_record_photo_quality_task( + state->project_db, &snapshot, task_kind, task_kind_version, &checkpoint, + quality_parameters, now.tv_sec) : lardon3d_project_db_record_task(state->project_db, &snapshot, task_kind, task_kind_version, &checkpoint, now.tv_sec); if (recorded == LARDON3D_PROJECT_DB_BUSY) { @@ -761,7 +766,8 @@ checkpoint_task_internal(Lardon3DAppState *state, const Lardon3DTask *task, Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_task(Lardon3DAppState *state, const Lardon3DTask *task) { - return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, + NULL); } Lardon3DProjectTaskCheckpointResult @@ -771,7 +777,7 @@ lardon3d_project_checkpoint_image_import_task(Lardon3DAppState *state, const Lar return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } return checkpoint_task_internal( - state, task, source_path, scanset_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + state, task, source_path, scanset_id, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_acquisition_campaign_task( @@ -779,6 +785,14 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_acquisition_camp const Lardon3DProjectDbAcquisitionCampaignTask *parameters) { if (!parameters) return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, + parameters, NULL); +} + +Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_photo_quality_task( + Lardon3DAppState *state, const Lardon3DTask *task, + const Lardon3DProjectDbPhotoQualityTask *parameters) { + if (!parameters) return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; + return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, parameters); } @@ -789,7 +803,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_feature_extract_ return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } return checkpoint_task_internal(state, task, NULL, 0, parameters, NULL, NULL, NULL, NULL, NULL, - NULL); + NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_sift_extract_task( @@ -797,7 +811,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_sift_extract_tas const Lardon3DProjectDbSiftExtractTask *parameters) { if (!parameters) return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; return checkpoint_task_internal(state, task, NULL, 0, NULL, parameters, NULL, NULL, NULL, NULL, - NULL); + NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_visual_index_update_task( @@ -807,7 +821,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_visual_index_upd return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, parameters, NULL, NULL, NULL, - NULL); + NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_candidate_pair_generate_task( @@ -817,7 +831,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_candidate_pair_g return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, parameters, NULL, NULL, - NULL); + NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_matcher_task( @@ -827,7 +841,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_matcher_task( return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, parameters, NULL, - NULL); + NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_geometric_verifier_task( @@ -837,7 +851,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_geometric_verifi return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, NULL, - parameters, NULL); + parameters, NULL, NULL); } static bool coherent_recovery(const Lardon3DProjectDbTask *database_task, diff --git a/src/project_db.c b/src/project_db.c index 572f0da..293ab89 100644 --- a/src/project_db.c +++ b/src/project_db.c @@ -622,6 +622,33 @@ static const char schema_acquisition_campaign_v20[] = "capture_id INTEGER NOT NULL REFERENCES captures(capture_id)," "PRIMARY KEY(task_id,group_id),UNIQUE(task_id,capture_id));"; +static const char schema_photo_quality_v21[] = + /* v21 stores pre-materialization operational group analysis. These rows + never define Capture, Asset, image_id, SHA-256, path, or basename identity. + next_group_id is canonical and one-based: 1 initially, N+1 when done. */ + "CREATE TABLE IF NOT EXISTS photo_quality_triage_tasks(" + "task_id INTEGER PRIMARY KEY REFERENCES tasks(task_id) ON DELETE CASCADE," + "scanset_id INTEGER NOT NULL REFERENCES scansets(scanset_id)," + "next_group_id INTEGER NOT NULL CHECK(next_group_id>=1 AND next_group_id<=4097)," + "group_count INTEGER NOT NULL CHECK(group_count>0 AND group_count<=4096)," + "request BLOB NOT NULL CHECK(length(request)>0)," + "CHECK(next_group_id<=group_count+1));" + "CREATE TABLE IF NOT EXISTS photo_quality_triage_results(" + "task_id INTEGER NOT NULL REFERENCES photo_quality_triage_tasks(task_id) ON DELETE CASCADE," + "group_id INTEGER NOT NULL CHECK(group_id>0 AND group_id<=4096)," + "proxy_source_index INTEGER NOT NULL CHECK(proxy_source_index>=0 AND proxy_source_index<=4294967295)," + "metrics_version INTEGER NOT NULL CHECK(metrics_version>0)," + "metric_status INTEGER NOT NULL CHECK(metric_status BETWEEN 0 AND 3)," + "recommendation INTEGER NOT NULL CHECK(recommendation BETWEEN 1 AND 3)," + "human_override INTEGER NOT NULL DEFAULT 0 CHECK(human_override BETWEEN 0 AND 2)," + "decoded_width INTEGER NOT NULL,decoded_height INTEGER NOT NULL," + "analysis_width INTEGER NOT NULL,analysis_height INTEGER NOT NULL," + "sharpness_raw REAL NOT NULL,sharpness_normalized REAL NOT NULL," + "clipped_black_fraction REAL NOT NULL,clipped_white_fraction REAL NOT NULL," + "contrast_raw REAL NOT NULL,contrast_normalized REAL NOT NULL," + "low_texture_fraction REAL NOT NULL,reasons TEXT NOT NULL CHECK(length(reasons)<256)," + "PRIMARY KEY(task_id,group_id));"; + static void copy_error(char destination[LARDON3D_PROJECT_DB_ERROR_CAPACITY], const char *text) { if (destination) { (void)snprintf(destination, LARDON3D_PROJECT_DB_ERROR_CAPACITY, "%s", text ? text : ""); @@ -713,7 +740,8 @@ static Lardon3DProjectDbResult migrate(Lardon3DProjectDb *database, unsigned int from_version != 4 && from_version != 5 && from_version != 6 && from_version != 7 && from_version != 8 && from_version != 9 && from_version != 10 && from_version != 11 && from_version != 12 && from_version != 13 && from_version != 14 && - from_version != 15 && from_version != 16 && from_version != 17 && from_version != 18) { + from_version != 15 && from_version != 16 && from_version != 17 && from_version != 18 && + from_version != 19 && from_version != 20) { return LARDON3D_PROJECT_DB_CORRUPT; } Lardon3DProjectDbResult result = execute(database, "BEGIN IMMEDIATE", "begin migration"); @@ -1126,6 +1154,15 @@ static Lardon3DProjectDbResult migrate(Lardon3DProjectDb *database, unsigned int "finish schema v20 migration"); } } + if (result == LARDON3D_PROJECT_DB_OK && from_version < 21) { + result = execute(database, schema_photo_quality_v21, + "migrate photo quality triage v20 to v21"); + if (result == LARDON3D_PROJECT_DB_OK) { + result = execute(database, + "UPDATE metadata SET value=21 WHERE key='schema_version' AND value=20", + "finish schema v21 migration"); + } + } if (result == LARDON3D_PROJECT_DB_OK) { result = execute(database, "COMMIT", "commit migration"); } @@ -1263,7 +1300,9 @@ Lardon3DProjectDbResult lardon3d_project_db_open(const char *path, Lardon3DProje "capture_selections", "asset_derivations", "acquisition_campaign_tasks", - "acquisition_campaign_captures"}; + "acquisition_campaign_captures", + "photo_quality_triage_tasks", + "photo_quality_triage_results"}; for (size_t index = 0; index < sizeof(required) / sizeof(required[0]) && result == LARDON3D_PROJECT_DB_OK; ++index) { @@ -1488,6 +1527,7 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps const Lardon3DProjectDbGeometricVerifierTask *geometric_verifier, const Lardon3DProjectDbSparseSfmTask *sparse_sfm, const Lardon3DProjectDbAcquisitionCampaignTask *campaign, + const Lardon3DProjectDbPhotoQualityTask *photo_quality, int64_t updated_at) { bool typed = task_kind != NULL; if (!database || !valid_durable_task(snapshot, updated_at) || @@ -1559,6 +1599,12 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps campaign->group_count > 4096 || campaign->next_group_id > campaign->group_count || !campaign->request || campaign->request_size == 0 || campaign->request_size > INT_MAX)) || + (photo_quality && + (!valid_task_id(photo_quality->task_id) || photo_quality->task_id != snapshot->id || + !valid_task_id(photo_quality->scanset_id) || photo_quality->group_count == 0 || + photo_quality->group_count > 4096 || photo_quality->next_group_id == 0 || + photo_quality->next_group_id > photo_quality->group_count + 1u || !photo_quality->request || + photo_quality->request_size == 0 || photo_quality->request_size > INT_MAX)) || (checkpoint && !valid_checkpoint(checkpoint))) { return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } @@ -1643,6 +1689,31 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps } } } + if (result == LARDON3D_PROJECT_DB_OK && photo_quality) { + /* Generic checkpoints may lag the atomic business-result transaction after + a crash. A stale retry may repeat the cursor but must never move it back. */ + result = prepare( + database, + "INSERT INTO photo_quality_triage_tasks(task_id,scanset_id,next_group_id,group_count," + "request) VALUES(?1,?2,?3,?4,?5) ON CONFLICT(task_id) DO UPDATE SET " + "next_group_id=excluded.next_group_id WHERE scanset_id=excluded.scanset_id AND " + "group_count=excluded.group_count AND request=excluded.request AND " + "excluded.next_group_id>=photo_quality_triage_tasks.next_group_id", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)photo_quality->task_id); + sqlite3_bind_int64(statement, 2, (sqlite3_int64)photo_quality->scanset_id); + sqlite3_bind_int64(statement, 3, photo_quality->next_group_id); + sqlite3_bind_int64(statement, 4, photo_quality->group_count); + sqlite3_bind_blob(statement, 5, photo_quality->request, (int)photo_quality->request_size, + SQLITE_TRANSIENT); + result = step_done(database, statement, "upsert photo quality task"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) { + copy_error(database->error, "Payload de triage photo immuable."); + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + } + } if (result == LARDON3D_PROJECT_DB_OK) { result = prepare(database, @@ -1977,7 +2048,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_task( Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, const char *task_kind, uint32_t task_kind_version, const Lardon3DProjectDbCheckpoint *checkpoint, int64_t updated_at) { return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, NULL, 0, - NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_image_import_task( @@ -1989,7 +2060,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_image_import_task( } return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, source_path, scanset_id, NULL, NULL, NULL, NULL, NULL, NULL, - NULL, NULL, updated_at); + NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_feature_extract_task( @@ -2000,7 +2071,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_feature_extract_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, NULL, 0, - parameters, NULL, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); + parameters, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_sift_extract_task( @@ -2010,7 +2081,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_sift_extract_task( const Lardon3DProjectDbSiftExtractTask *parameters, int64_t updated_at) { if (!parameters) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, NULL, 0, - NULL, parameters, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); + NULL, parameters, NULL, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); } static bool read_task(sqlite3_stmt *statement, Lardon3DProjectDbTask *task) { @@ -2254,7 +2325,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_acquisition_campaign_task( /* The generic Task/checkpoint and typed request persist atomically: recovery never observes a valid campaign Task without its immutable request blob. */ return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, - NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, parameters, + NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, parameters, NULL, updated_at); } @@ -2399,6 +2470,316 @@ Lardon3DProjectDbResult lardon3d_project_db_load_acquisition_campaign_capture( return result; } +Lardon3DProjectDbResult lardon3d_project_db_record_photo_quality_task( + Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, + const char *task_kind, uint32_t task_kind_version, + const Lardon3DProjectDbCheckpoint *checkpoint, + const Lardon3DProjectDbPhotoQualityTask *parameters, int64_t updated_at) { + if (!parameters) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + /* Generic runtime state and typed immutable request share the transaction; + recovery cannot observe one without the other. */ + return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, NULL, 0, + NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, parameters, + updated_at); +} + +Lardon3DProjectDbResult lardon3d_project_db_load_photo_quality_task( + Lardon3DProjectDb *database, uint64_t task_id, unsigned char *request, + size_t request_capacity, Lardon3DProjectDbPhotoQualityTask *parameters) { + if (!database || !valid_task_id(task_id) || !request || !request_capacity || !parameters) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(parameters, 0, sizeof(*parameters)); + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT scanset_id,next_group_id,group_count,request FROM photo_quality_triage_tasks " + "WHERE task_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) result = sqlite_result(database, code, "load photo quality task"); + else { + bool integer_columns = sqlite3_column_type(statement, 0) == SQLITE_INTEGER && + sqlite3_column_type(statement, 1) == SQLITE_INTEGER && + sqlite3_column_type(statement, 2) == SQLITE_INTEGER; + sqlite3_int64 scanset = sqlite3_column_int64(statement, 0); + sqlite3_int64 next = sqlite3_column_int64(statement, 1); + sqlite3_int64 count = sqlite3_column_int64(statement, 2); + int bytes = sqlite3_column_bytes(statement, 3); + const void *blob = sqlite3_column_blob(statement, 3); + /* SQLite exposes signed 64-bit storage. Validate identity, operational + bounds, and the one-based cursor relation before narrowing to the C API. + N+1 is the only completed cursor; zero and values beyond N+1 are corrupt. */ + if (!integer_columns || sqlite3_column_type(statement, 3) != SQLITE_BLOB || scanset <= 0 || + next < 1 || count < 1 || count > 4096 || + next > count + 1 || bytes <= 0 || + (size_t)bytes > request_capacity || !blob) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + memcpy(request, blob, (size_t)bytes); + parameters->task_id = task_id; + parameters->scanset_id = (uint64_t)scanset; + parameters->next_group_id = (uint32_t)next; + parameters->group_count = (uint32_t)count; + parameters->request = request; + parameters->request_size = (size_t)bytes; + } + } + sqlite3_finalize(statement); + } + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +static bool valid_quality_result(const Lardon3DProjectDbPhotoQualityResult *r) { + const Lardon3DPhotoQualityMetrics *m = r ? &r->metrics : NULL; + bool decoded_pair = m && ((m->decoded_width == 0) == (m->decoded_height == 0)); + bool analysis_pair = m && ((m->analysis_width == 0) == (m->analysis_height == 0)); + bool ok_dimensions = m && m->status == LARDON3D_PHOTO_QUALITY_METRIC_OK && + m->decoded_width > 0 && m->analysis_width > 0 && + m->decoded_width <= LARDON3D_PHOTO_QUALITY_JPEG_MAX_DIMENSION && + m->decoded_height <= LARDON3D_PHOTO_QUALITY_JPEG_MAX_DIMENSION; + bool unavailable_dimensions = m && m->status == LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE && + (m->decoded_width == 0 || + m->decoded_width > LARDON3D_PHOTO_QUALITY_JPEG_MAX_DIMENSION || + m->decoded_height > LARDON3D_PHOTO_QUALITY_JPEG_MAX_DIMENSION); + bool failed_dimensions = m && + (m->status == LARDON3D_PHOTO_QUALITY_METRIC_INVALID_INPUT || + m->status == LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR) && + m->analysis_width == 0; + bool truthful_recommendation = m && + ((m->status == LARDON3D_PHOTO_QUALITY_METRIC_OK) || + (m->status == LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE && + m->recommendation == LARDON3D_PHOTO_QUALITY_SUSPECT) || + ((m->status == LARDON3D_PHOTO_QUALITY_METRIC_INVALID_INPUT || + m->status == LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR) && + m->recommendation == LARDON3D_PHOTO_QUALITY_REJECT)); + return r && valid_task_id(r->task_id) && m && + m->metrics_version == LARDON3D_PHOTO_QUALITY_METRICS_VERSION && + m->status >= LARDON3D_PHOTO_QUALITY_METRIC_OK && + m->status <= LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR && + m->recommendation >= LARDON3D_PHOTO_QUALITY_GOOD && + m->recommendation <= LARDON3D_PHOTO_QUALITY_REJECT && + r->override_value >= LARDON3D_PHOTO_QUALITY_OVERRIDE_NONE && + r->override_value <= LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE && + decoded_pair && analysis_pair && + m->analysis_width <= LARDON3D_PHOTO_QUALITY_ANALYSIS_MAX_DIMENSION && + m->analysis_height <= LARDON3D_PHOTO_QUALITY_ANALYSIS_MAX_DIMENSION && + (ok_dimensions || unavailable_dimensions || failed_dimensions) && + truthful_recommendation && + bounded_text(m->reasons, sizeof(m->reasons), false) && isfinite(m->sharpness_raw) && + isfinite(m->sharpness_normalized) && isfinite(m->clipped_black_fraction) && + isfinite(m->clipped_white_fraction) && isfinite(m->contrast_raw) && + isfinite(m->contrast_normalized) && isfinite(m->low_texture_fraction) && + m->sharpness_raw >= 0.0 && m->sharpness_normalized >= 0.0 && m->contrast_raw >= 0.0 && + m->contrast_normalized >= 0.0 && m->clipped_black_fraction >= 0.0 && + m->clipped_black_fraction <= 1.0 && m->clipped_white_fraction >= 0.0 && + m->clipped_white_fraction <= 1.0 && m->low_texture_fraction >= 0.0 && + m->low_texture_fraction <= 1.0; +} + +Lardon3DProjectDbResult lardon3d_project_db_record_photo_quality_result( + Lardon3DProjectDb *database, const Lardon3DProjectDbPhotoQualityResult *r, + uint32_t next_group_id) { + /* First publication requires the canonical one-based cursor at group_id and + atomically advances it to group_id+1. An exact retained replay also + converges when that advance is already durable; it may neither replace + result identity/data nor accept any other cursor. N+1 is completion. */ + if (!database || !valid_quality_result(r) || r->group_id == 0 || + r->group_id == UINT32_MAX || next_group_id != r->group_id + 1u) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + Lardon3DProjectDbResult result = execute(database, "BEGIN IMMEDIATE", "begin quality result"); + sqlite3_stmt *statement = NULL; + if (result == LARDON3D_PROJECT_DB_OK) { + const Lardon3DPhotoQualityMetrics *m = &r->metrics; + result = prepare(database, + "INSERT INTO photo_quality_triage_results(task_id,group_id,proxy_source_index," + "metrics_version,metric_status,recommendation,human_override,decoded_width,decoded_height," + "analysis_width,analysis_height,sharpness_raw,sharpness_normalized,clipped_black_fraction," + "clipped_white_fraction,contrast_raw,contrast_normalized,low_texture_fraction,reasons) " + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19) " + "ON CONFLICT(task_id,group_id) DO UPDATE SET group_id=excluded.group_id WHERE " + "proxy_source_index IS excluded.proxy_source_index AND " + "metrics_version IS excluded.metrics_version AND metric_status IS excluded.metric_status " + "AND recommendation IS excluded.recommendation AND human_override IS excluded.human_override " + "AND decoded_width IS excluded.decoded_width AND decoded_height IS excluded.decoded_height " + "AND analysis_width IS excluded.analysis_width AND analysis_height IS excluded.analysis_height " + "AND sharpness_raw IS excluded.sharpness_raw AND " + "sharpness_normalized IS excluded.sharpness_normalized AND " + "clipped_black_fraction IS excluded.clipped_black_fraction AND " + "clipped_white_fraction IS excluded.clipped_white_fraction AND " + "contrast_raw IS excluded.contrast_raw AND " + "contrast_normalized IS excluded.contrast_normalized AND " + "low_texture_fraction IS excluded.low_texture_fraction AND reasons IS excluded.reasons", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)r->task_id); + sqlite3_bind_int64(statement, 2, r->group_id); + sqlite3_bind_int64(statement, 3, r->proxy_source_index); + sqlite3_bind_int64(statement, 4, m->metrics_version); + sqlite3_bind_int(statement, 5, (int)m->status); + sqlite3_bind_int(statement, 6, (int)m->recommendation); + sqlite3_bind_int(statement, 7, (int)r->override_value); + sqlite3_bind_int64(statement, 8, m->decoded_width); + sqlite3_bind_int64(statement, 9, m->decoded_height); + sqlite3_bind_int64(statement, 10, m->analysis_width); + sqlite3_bind_int64(statement, 11, m->analysis_height); + sqlite3_bind_double(statement, 12, m->sharpness_raw); + sqlite3_bind_double(statement, 13, m->sharpness_normalized); + sqlite3_bind_double(statement, 14, m->clipped_black_fraction); + sqlite3_bind_double(statement, 15, m->clipped_white_fraction); + sqlite3_bind_double(statement, 16, m->contrast_raw); + sqlite3_bind_double(statement, 17, m->contrast_normalized); + sqlite3_bind_double(statement, 18, m->low_texture_fraction); + sqlite3_bind_text(statement, 19, m->reasons, -1, SQLITE_TRANSIENT); + result = step_done(database, statement, "insert quality result"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + } + if (result == LARDON3D_PROJECT_DB_OK) { + result = prepare(database, + "UPDATE photo_quality_triage_tasks SET next_group_id=?3 WHERE task_id=?1 AND " + "(next_group_id=?2 OR next_group_id=?3) AND group_count>=?2 AND ?3=?2+1", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)r->task_id); + sqlite3_bind_int64(statement, 2, (sqlite3_int64)r->group_id); + sqlite3_bind_int64(statement, 3, next_group_id); + result = step_done(database, statement, "advance quality cursor"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + } + if (result == LARDON3D_PROJECT_DB_OK) result = execute(database, "COMMIT", "commit quality result"); + else (void)execute(database, "ROLLBACK", "rollback quality result"); + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_project_db_load_photo_quality_result( + Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, + Lardon3DProjectDbPhotoQualityResult *result_row) { + if (!database || !valid_task_id(task_id) || group_id == 0 || !result_row) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + memset(result_row, 0, sizeof(*result_row)); + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, + "SELECT proxy_source_index,metrics_version,metric_status,recommendation,human_override," + "decoded_width,decoded_height,analysis_width,analysis_height,sharpness_raw," + "sharpness_normalized,clipped_black_fraction,clipped_white_fraction,contrast_raw," + "r.contrast_normalized,r.low_texture_fraction,r.reasons,t.group_count,t.next_group_id " + "FROM photo_quality_triage_results AS r JOIN photo_quality_triage_tasks AS t " + "ON t.task_id=r.task_id WHERE r.task_id=?1 AND r.group_id=?2", &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + sqlite3_bind_int64(statement, 2, group_id); + int code = sqlite3_step(statement); + if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND; + else if (code != SQLITE_ROW) result = sqlite_result(database, code, "load quality result"); + else { + bool integer_columns = true; + for (int column = 0; column <= 8; ++column) + integer_columns = integer_columns && + sqlite3_column_type(statement, column) == SQLITE_INTEGER; + bool numeric_columns = true; + for (int column = 9; column <= 15; ++column) { + int type = sqlite3_column_type(statement, column); + numeric_columns = numeric_columns && (type == SQLITE_FLOAT || type == SQLITE_INTEGER); + } + sqlite3_int64 proxy = sqlite3_column_int64(statement, 0); + sqlite3_int64 version = sqlite3_column_int64(statement, 1); + sqlite3_int64 status = sqlite3_column_int64(statement, 2); + sqlite3_int64 recommendation = sqlite3_column_int64(statement, 3); + sqlite3_int64 override_value = sqlite3_column_int64(statement, 4); + sqlite3_int64 decoded_width = sqlite3_column_int64(statement, 5); + sqlite3_int64 decoded_height = sqlite3_column_int64(statement, 6); + sqlite3_int64 analysis_width = sqlite3_column_int64(statement, 7); + sqlite3_int64 analysis_height = sqlite3_column_int64(statement, 8); + /* Corrupt SQLite rows must not wrap through enum or uint32 casts. Keep + values wide until every sign/range constraint has passed. */ + sqlite3_int64 group_count = sqlite3_column_int64(statement, 17); + sqlite3_int64 next_group_id = sqlite3_column_int64(statement, 18); + /* A retained result is publishable only after its canonical plan group + is durably completed. Validate the typed cursor while values are still + SQLite-width so corruption cannot wrap through public uint32 fields. */ + if (!integer_columns || !numeric_columns || + sqlite3_column_type(statement, 16) != SQLITE_TEXT || proxy < 0 || proxy > UINT32_MAX || + sqlite3_column_type(statement, 17) != SQLITE_INTEGER || + sqlite3_column_type(statement, 18) != SQLITE_INTEGER || group_count < 1 || + group_count > 4096 || next_group_id < 1 || next_group_id > group_count + 1 || + (sqlite3_int64)group_id > group_count || (sqlite3_int64)group_id >= next_group_id || + version != LARDON3D_PHOTO_QUALITY_METRICS_VERSION || + status < LARDON3D_PHOTO_QUALITY_METRIC_OK || + status > LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR || + recommendation < LARDON3D_PHOTO_QUALITY_GOOD || + recommendation > LARDON3D_PHOTO_QUALITY_REJECT || + override_value < LARDON3D_PHOTO_QUALITY_OVERRIDE_NONE || + override_value > LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE || + decoded_width < 0 || decoded_width > UINT32_MAX || decoded_height < 0 || + decoded_height > UINT32_MAX || analysis_width < 0 || analysis_width > UINT32_MAX || + analysis_height < 0 || analysis_height > UINT32_MAX) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + result_row->task_id = task_id; + result_row->group_id = group_id; + result_row->proxy_source_index = (uint32_t)proxy; + Lardon3DPhotoQualityMetrics *m = &result_row->metrics; + m->metrics_version = (uint32_t)version; + m->status = (Lardon3DPhotoQualityMetricStatus)status; + m->recommendation = (Lardon3DPhotoQualityRecommendation)recommendation; + result_row->override_value = (Lardon3DPhotoQualityOverride)override_value; + m->decoded_width = (uint32_t)decoded_width; + m->decoded_height = (uint32_t)decoded_height; + m->analysis_width = (uint32_t)analysis_width; + m->analysis_height = (uint32_t)analysis_height; + m->sharpness_raw = sqlite3_column_double(statement, 9); + m->sharpness_normalized = sqlite3_column_double(statement, 10); + m->clipped_black_fraction = sqlite3_column_double(statement, 11); + m->clipped_white_fraction = sqlite3_column_double(statement, 12); + m->contrast_raw = sqlite3_column_double(statement, 13); + m->contrast_normalized = sqlite3_column_double(statement, 14); + m->low_texture_fraction = sqlite3_column_double(statement, 15); + const unsigned char *reasons = sqlite3_column_text(statement, 16); + if (!reasons || snprintf(m->reasons, sizeof(m->reasons), "%s", reasons) >= + (int)sizeof(m->reasons) || !valid_quality_result(result_row)) + result = LARDON3D_PROJECT_DB_CORRUPT; + } + } + sqlite3_finalize(statement); + } + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + +Lardon3DProjectDbResult lardon3d_project_db_set_photo_quality_override( + Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, + Lardon3DPhotoQualityOverride override_value) { + if (!database || !valid_task_id(task_id) || group_id == 0 || override_value < 0 || + override_value > 2) + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare(database, + "UPDATE photo_quality_triage_results SET human_override=?3 WHERE task_id=?1 AND group_id=?2", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + sqlite3_bind_int64(statement, 2, group_id); + sqlite3_bind_int(statement, 3, (int)override_value); + result = step_done(database, statement, "set quality override"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) + result = LARDON3D_PROJECT_DB_NOT_FOUND; + } + (void)pthread_mutex_unlock(&database->mutex); + return result; +} + static bool valid_catalog_id(uint64_t value) { return value > 0 && value <= INT64_MAX; } static bool valid_original_name(const char *name) { @@ -4984,7 +5365,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_visual_index_update_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, parameters, NULL, NULL, NULL, NULL, NULL, updated_at); + NULL, parameters, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_visual_index_update_task( @@ -5031,7 +5412,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_candidate_pair_generate_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, NULL, parameters, NULL, NULL, NULL, NULL, updated_at); + NULL, NULL, parameters, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_candidate_pair_generate_task( @@ -5087,7 +5468,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_matcher_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, NULL, NULL, parameters, NULL, NULL, NULL, updated_at); + NULL, NULL, NULL, parameters, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_matcher_task( @@ -5145,7 +5526,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_geometric_verifier_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, NULL, NULL, NULL, parameters, NULL, NULL, updated_at); + NULL, NULL, NULL, NULL, parameters, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_geometric_verifier_task( @@ -5218,7 +5599,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_track_builder_task( } Lardon3DProjectDbResult result = record_task_internal( db, snapshot, kind, version, checkpoint, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, - NULL, updated_at); + NULL, NULL, updated_at); if (result != LARDON3D_PROJECT_DB_OK) return result; (void)pthread_mutex_lock(&db->mutex); result = execute(db, "BEGIN IMMEDIATE", "begin track builder task"); @@ -5329,7 +5710,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_sparse_sfm_task( const Lardon3DProjectDbSparseSfmTask *parameters, int64_t updated_at) { if (!parameters) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; return record_task_internal(database, snapshot, kind, version, checkpoint, NULL, 0, NULL, NULL, - NULL, NULL, NULL, NULL, parameters, NULL, updated_at); + NULL, NULL, NULL, NULL, parameters, NULL, NULL, updated_at); } static bool read_u64_blob(sqlite3_stmt *statement, int column, uint64_t *value) { diff --git a/src/task_kinds.c b/src/task_kinds.c index 65bb37c..9f7212f 100644 --- a/src/task_kinds.c +++ b/src/task_kinds.c @@ -3,6 +3,9 @@ #include #endif #include +#ifdef LARDON3D_PHOTO_QUALITY_TASK_AVAILABLE +#include +#endif #include #include #include @@ -21,6 +24,13 @@ const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_production(void) { static const Lardon3DTaskKindDescriptor descriptors[] = { +#ifdef LARDON3D_PHOTO_QUALITY_TASK_AVAILABLE + { + .kind = LARDON3D_PHOTO_QUALITY_TASK_KIND, + .kind_version = LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION, + .reconstruct = lardon3d_photo_quality_task_reconstruct, + }, +#endif #ifdef LARDON3D_ACQUISITION_CAMPAIGN_TASK_AVAILABLE { .kind = LARDON3D_ACQUISITION_CAMPAIGN_TASK_KIND, diff --git a/tests/photo_quality_real_scan.cpp b/tests/photo_quality_real_scan.cpp new file mode 100644 index 0000000..1486240 --- /dev/null +++ b/tests/photo_quality_real_scan.cpp @@ -0,0 +1,418 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +namespace { + +constexpr uint64_t kAnalysisWorkingBytes = UINT64_C(20) * 1024u * 1024u; + +enum class Mode { kA6000, kS21 }; + +struct Options { + Mode mode{}; + bool has_mode{}; + std::filesystem::path project_dir; + std::vector roots; +}; + +/* This validation-only mirror keeps the reported admission estimate aligned + * with the v1 Task contract: retained request/context storage plus one bounded + * 20 MiB group analysis allowance. It does not own or execute Task state. */ +struct PhotoQualityContextLayout { + char project_path[LARDON3D_APP_STATE_PATH_CAPACITY]; + Lardon3DProjectDb *database; + Lardon3DResourceGovernor *governor; + uint64_t scanset_id; + std::vector sources; + std::vector confirmations; + std::vector encoded; + Lardon3DAcquisitionCampaignPlan plan; +}; + +void usage(const char *program) { + std::fprintf(stderr, + "Usage: %s --mode a6000|s21 --project-dir ABSOLUTE_EMPTY_DIR " + "--root ABSOLUTE_DIR [--root ABSOLUTE_DIR ...]\n", + program); +} + +bool parse_options(int argc, char **argv, Options &options) { + for (int i = 1; i < argc; ++i) { + const std::string argument(argv[i]); + if ((argument == "--mode" || argument == "--project-dir" || argument == "--root") && + i + 1 >= argc) + return false; + if (argument == "--mode") { + const std::string value(argv[++i]); + if (value == "a6000") options.mode = Mode::kA6000; + else if (value == "s21") options.mode = Mode::kS21; + else return false; + options.has_mode = true; + } else if (argument == "--project-dir") { + options.project_dir = argv[++i]; + } else if (argument == "--root") { + options.roots.emplace_back(argv[++i]); + } else if (argument == "--help") { + usage(argv[0]); + std::exit(0); + } else { + return false; + } + } + return options.has_mode && !options.project_dir.empty() && !options.roots.empty() && + options.roots.size() <= LARDON3D_ACQUISITION_CAMPAIGN_MAX_ROOTS; +} + +bool canonical_directory(const std::filesystem::path &input, std::filesystem::path &output) { + std::error_code error; + if (!input.is_absolute()) return false; + output = std::filesystem::canonical(input, error); + return !error && std::filesystem::is_directory(output, error) && !error && + output.string().size() < LARDON3D_ACQUISITION_CAMPAIGN_PATH_CAPACITY; +} + +bool prepare_project_directory(const std::filesystem::path &input, + std::filesystem::path &output) { + if (!input.is_absolute()) return false; + output = input.lexically_normal(); + if (output != input || output.string().size() >= LARDON3D_APP_STATE_PATH_CAPACITY) return false; + std::error_code error; + if (std::filesystem::exists(output, error)) { + if (error || !std::filesystem::is_directory(output, error) || error || + !std::filesystem::is_empty(output, error) || error) + return false; + } else if (!std::filesystem::create_directories(output, error) || error) { + return false; + } + return std::filesystem::create_directories(output / ".lardon3d" / "checkpoints", error) && + !error; +} + +const char *mode_name(Mode mode) { return mode == Mode::kA6000 ? "a6000" : "s21"; } + +std::string stem(const char *path) { + const char *name = std::strrchr(path, '/'); + name = name == nullptr ? path : name + 1; + const char *dot = std::strrchr(name, '.'); + return std::string(name, dot == nullptr ? std::strlen(name) : static_cast(dot - name)); +} + +bool build_confirmations(Mode mode, const Lardon3DAcquisitionCampaignDiscovery &discovery, + std::vector &confirmations) { + if (mode == Mode::kS21) { + confirmations.reserve(discovery.source_count); + for (size_t i = 0; i < discovery.source_count; ++i) { + if (discovery.sources[i].source_kind != LARDON3D_ACQUISITION_SOURCE_JPEG) return false; + Lardon3DAcquisitionCampaignConfirmation confirmation{}; + confirmation.source_count = 1; + confirmation.source_indices[0] = i; + confirmations.push_back(confirmation); + } + return true; + } + + struct Pair { size_t raw = SIZE_MAX; size_t jpeg = SIZE_MAX; }; + std::map pairs; + for (size_t i = 0; i < discovery.source_count; ++i) { + Pair &pair = pairs[stem(discovery.sources[i].path)]; + if (discovery.sources[i].source_kind == LARDON3D_ACQUISITION_SOURCE_RAW) { + if (pair.raw != SIZE_MAX) return false; + pair.raw = i; + } else if (discovery.sources[i].source_kind == LARDON3D_ACQUISITION_SOURCE_JPEG) { + if (pair.jpeg != SIZE_MAX) return false; + pair.jpeg = i; + } else { + return false; + } + } + confirmations.reserve(pairs.size()); + for (const auto &entry : pairs) { + if (entry.second.raw == SIZE_MAX || entry.second.jpeg == SIZE_MAX) return false; + Lardon3DAcquisitionCampaignConfirmation confirmation{}; + confirmation.source_count = 2; + confirmation.source_indices[0] = entry.second.raw; + confirmation.source_indices[1] = entry.second.jpeg; + confirmations.push_back(confirmation); + } + return confirmations.size() * 2u == discovery.source_count; +} + +void json_string(const char *text) { + std::putchar('"'); + for (const unsigned char *p = reinterpret_cast(text); *p; ++p) { + switch (*p) { + case '"': std::fputs("\\\"", stdout); break; + case '\\': std::fputs("\\\\", stdout); break; + case '\b': std::fputs("\\b", stdout); break; + case '\f': std::fputs("\\f", stdout); break; + case '\n': std::fputs("\\n", stdout); break; + case '\r': std::fputs("\\r", stdout); break; + case '\t': std::fputs("\\t", stdout); break; + default: + if (*p < 0x20u) std::printf("\\u%04x", static_cast(*p)); + else std::putchar(*p); + } + } + std::putchar('"'); +} + +const char *status_name(Lardon3DPhotoQualityMetricStatus status) { + switch (status) { + case LARDON3D_PHOTO_QUALITY_METRIC_OK: return "OK"; + case LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE: return "UNAVAILABLE"; + case LARDON3D_PHOTO_QUALITY_METRIC_INVALID_INPUT: return "INVALID_INPUT"; + case LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR: return "DECODE_ERROR"; + } + return "UNKNOWN"; +} + +const char *recommendation_name(Lardon3DPhotoQualityRecommendation recommendation) { + switch (recommendation) { + case LARDON3D_PHOTO_QUALITY_GOOD: return "GOOD"; + case LARDON3D_PHOTO_QUALITY_SUSPECT: return "SUSPECT"; + case LARDON3D_PHOTO_QUALITY_REJECT: return "REJECT"; + } + return "NONE"; +} + +bool wait_terminal(Lardon3DTaskQueue *queue, Lardon3DProjectDb *database, uint64_t task_id, + Lardon3DTaskSnapshot &snapshot, Lardon3DProjectDbTask &durable) { + for (;;) { + if (!lardon3d_task_queue_get(queue, task_id, &snapshot)) return false; + if (snapshot.state == TASK_COMPLETED || snapshot.state == TASK_FAILED || + snapshot.state == TASK_CANCELLED) { + /* Queue terminal state precedes the finished callback. Report only after + * that callback has durably published the matching generic Task state and + * sequence count, so validation never samples an intermediate row. */ + if (lardon3d_project_db_load_task(database, task_id, &durable) != LARDON3D_PROJECT_DB_OK) + return false; + if (durable.saved_state == snapshot.state) return true; + } + usleep(100000); + } +} + +void emit_group(const Lardon3DAcquisitionCampaignDiscovery &discovery, + const Lardon3DAcquisitionCampaignGroup &group, + const Lardon3DProjectDbPhotoQualityResult &result) { + std::printf("{\"record\":\"group\",\"group_id\":%u,\"basis\":%u,\"source_indices\":[", + result.group_id, static_cast(group.basis)); + for (size_t i = 0; i < group.source_count; ++i) + std::printf("%s%zu", i == 0 ? "" : ",", group.source_indices[i]); + std::fputs("],\"source_paths\":[", stdout); + for (size_t i = 0; i < group.source_count; ++i) { + if (i != 0) std::putchar(','); + json_string(discovery.sources[group.source_indices[i]].path); + } + std::fputs("],\"proxy_source_index\":", stdout); + if (result.proxy_source_index == UINT32_MAX) std::fputs("null,\"proxy_path\":null", stdout); + else { + std::printf("%u,\"proxy_path\":", result.proxy_source_index); + json_string(discovery.sources[result.proxy_source_index].path); + } + std::printf(",\"status\":\"%s\",\"recommendation\":\"%s\",\"reasons\":", + status_name(result.metrics.status), + recommendation_name(result.metrics.recommendation)); + json_string(result.metrics.reasons); + std::printf(",\"decoded_width\":%u,\"decoded_height\":%u,\"analysis_width\":%u," + "\"analysis_height\":%u,\"sharpness_raw\":%.17g," + "\"sharpness_normalized\":%.17g,\"clipped_black_fraction\":%.17g," + "\"clipped_white_fraction\":%.17g,\"contrast_raw\":%.17g," + "\"contrast_normalized\":%.17g,\"low_texture_fraction\":%.17g}\n", + result.metrics.decoded_width, result.metrics.decoded_height, + result.metrics.analysis_width, result.metrics.analysis_height, + result.metrics.sharpness_raw, result.metrics.sharpness_normalized, + result.metrics.clipped_black_fraction, result.metrics.clipped_white_fraction, + result.metrics.contrast_raw, result.metrics.contrast_normalized, + result.metrics.low_texture_fraction); +} + +} // namespace + +extern "C" const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_production(void) { + return nullptr; +} + +int main(int argc, char **argv) { + Options options; + if (!parse_options(argc, argv, options)) { usage(argv[0]); return 2; } + + std::filesystem::path project_dir; + if (!prepare_project_directory(options.project_dir, project_dir)) { + std::fprintf(stderr, "project directory must be an absolute, normalized empty directory\n"); + return 2; + } + std::vector roots(options.roots.size()); + for (size_t i = 0; i < options.roots.size(); ++i) { + std::filesystem::path canonical; + if (!canonical_directory(options.roots[i], canonical)) { + std::fprintf(stderr, "root must resolve to a bounded directory: %s\n", + options.roots[i].c_str()); + return 2; + } + const std::string path = canonical.string(); + std::memcpy(roots[i].path, path.c_str(), path.size() + 1u); + } + + auto discovery = std::make_unique(); + const auto discovery_result = + lardon3d_acquisition_campaign_discover(roots.data(), roots.size(), discovery.get()); + if (discovery_result != LARDON3D_ACQUISITION_CAMPAIGN_OK || discovery->source_count == 0) { + std::fprintf(stderr, "acquisition discovery failed: %u\n", + static_cast(discovery_result)); + return 1; + } + std::vector confirmations; + if (!build_confirmations(options.mode, *discovery, confirmations)) { + std::fprintf(stderr, "%s source set does not satisfy the required exact grouping shape\n", + mode_name(options.mode)); + return 1; + } + auto plan = std::make_unique(); + if (lardon3d_acquisition_campaign_plan(discovery->sources, discovery->source_count, + confirmations.data(), confirmations.size(), plan.get()) != + LARDON3D_ACQUISITION_CAMPAIGN_OK || + plan->group_count != confirmations.size() || + plan->summary.explicit_group_count != confirmations.size()) { + std::fprintf(stderr, "explicit campaign planning failed\n"); + return 1; + } + + const std::string database_path = (project_dir / "project.lardon3d").string(); + char database_error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{}; + Lardon3DProjectDb *database = nullptr; + if (lardon3d_project_db_open(database_path.c_str(), &database, database_error) != + LARDON3D_PROJECT_DB_OK) { + std::fprintf(stderr, "project database open failed: %s\n", database_error); + return 1; + } + Lardon3DProjectDbScanSet scanset{}; + if (lardon3d_project_db_create_scanset(database, mode_name(options.mode), &scanset) != + LARDON3D_PROJECT_DB_OK) { + std::fprintf(stderr, "ScanSet creation failed\n"); + lardon3d_project_db_close(database); + return 1; + } + + Lardon3DHardwareProfile profile{}; + char hardware_error[256]{}; + Lardon3DResourcePolicy policy{}; + if (!lardon3d_hardware_profile_detect(&profile, hardware_error, sizeof(hardware_error)) || + !lardon3d_resource_policy_default(&profile, &policy)) { + std::fprintf(stderr, "hardware/resource policy detection failed: %s\n", hardware_error); + lardon3d_project_db_close(database); + return 1; + } + Lardon3DResourceGovernor *governor = lardon3d_resource_governor_create(&profile, &policy); + Lardon3DTaskQueue *queue = governor ? lardon3d_task_queue_create(governor, 2) : nullptr; + if (!queue) { + std::fprintf(stderr, "Queue/Governor creation failed\n"); + lardon3d_resource_governor_destroy(governor); + lardon3d_project_db_close(database); + return 1; + } + Lardon3DAppState state{}; + lardon3d_app_state_init(&state); + state.project_loaded = true; + state.project_db = database; + state.resource_governor = governor; + state.task_queue = queue; + state.hardware_profile = profile; + const std::string project_path = project_dir.string(); + std::memcpy(state.project_path, project_path.c_str(), project_path.size() + 1u); + + Lardon3DPhotoQualityTaskRequest request{discovery->sources, discovery->source_count, + confirmations.data(), confirmations.size()}; + size_t encoded_size = 0; + if (!lardon3d_photo_quality_request_encode(&request, nullptr, 0, &encoded_size)) { + std::fprintf(stderr, "request size calculation failed\n"); + lardon3d_task_queue_destroy(queue); + lardon3d_resource_governor_destroy(governor); + lardon3d_project_db_close(database); + return 1; + } + const uint64_t task_memory_estimate = + kAnalysisWorkingBytes + sizeof(PhotoQualityContextLayout) + + discovery->source_count * sizeof(Lardon3DAcquisitionCampaignSource) + + confirmations.size() * sizeof(Lardon3DAcquisitionCampaignConfirmation) + encoded_size; + const auto started = std::chrono::steady_clock::now(); + uint64_t task_id = 0; + if (!lardon3d_project_enqueue_photo_quality(&state, scanset.scanset_id, &request, &task_id)) { + std::fprintf(stderr, "photo_quality.triage enqueue failed\n"); + lardon3d_task_queue_destroy(queue); + lardon3d_resource_governor_destroy(governor); + lardon3d_project_db_close(database); + return 1; + } + Lardon3DTaskSnapshot terminal{}; + Lardon3DProjectDbTask durable_task{}; + if (!wait_terminal(queue, database, task_id, terminal, durable_task) || + terminal.state != TASK_COMPLETED) { + std::fprintf(stderr, "photo_quality.triage ended in %s: %s\n", + lardon3d_task_state_name(terminal.state), terminal.message); + lardon3d_task_queue_destroy(queue); + lardon3d_resource_governor_destroy(governor); + lardon3d_project_db_close(database); + return 1; + } + + size_t good = 0, suspect = 0, reject = 0, unavailable = 0, metric_errors = 0; + for (uint32_t group_index = 0; group_index < plan->group_count; ++group_index) { + const uint32_t group_id = plan->groups[group_index].group_id; + Lardon3DProjectDbPhotoQualityResult result{}; + if (lardon3d_project_db_load_photo_quality_result(database, task_id, group_id, &result) != + LARDON3D_PROJECT_DB_OK) { + std::fprintf(stderr, "missing durable result for group %u\n", group_id); + lardon3d_task_queue_destroy(queue); + lardon3d_resource_governor_destroy(governor); + lardon3d_project_db_close(database); + return 1; + } + if (result.metrics.status == LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE) ++unavailable; + else if (result.metrics.status != LARDON3D_PHOTO_QUALITY_METRIC_OK) ++metric_errors; + if (result.metrics.recommendation == LARDON3D_PHOTO_QUALITY_GOOD) ++good; + else if (result.metrics.recommendation == LARDON3D_PHOTO_QUALITY_SUSPECT) ++suspect; + else if (result.metrics.recommendation == LARDON3D_PHOTO_QUALITY_REJECT) ++reject; + emit_group(*discovery, plan->groups[group_index], result); + } + struct rusage usage{}; + const bool loaded_usage = getrusage(RUSAGE_SELF, &usage) == 0; + const double elapsed = + std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + std::printf("{\"record\":\"summary\",\"mode\":\"%s\",\"task_id\":%llu," + "\"sources\":%zu,\"groups\":%zu,\"good\":%zu,\"suspect\":%zu," + "\"reject\":%zu,\"unavailable\":%zu,\"metric_errors\":%zu," + "\"discovered_entries\":%zu,\"unsupported_entries\":%zu," + "\"metadata_ok\":%zu,\"metadata_errors\":%zu," + "\"request_encoded_bytes\":%zu,\"task_memory_estimate_bytes\":%llu," + "\"sequence_count\":%u,\"elapsed_seconds\":%.6f,\"peak_rss_kib\":%ld}\n", + mode_name(options.mode), static_cast(task_id), + discovery->source_count, plan->group_count, good, suspect, reject, unavailable, + metric_errors, discovery->summary.discovered_entry_count, + discovery->summary.unsupported_entry_count, discovery->summary.metadata_ok_count, + discovery->summary.metadata_error_count, encoded_size, + static_cast(task_memory_estimate), + durable_task.sequence_count, elapsed, + loaded_usage ? usage.ru_maxrss : -1L); + + lardon3d_task_queue_destroy(queue); + lardon3d_resource_governor_destroy(governor); + lardon3d_project_db_close(database); + return loaded_usage ? 0 : 1; +} diff --git a/tests/photo_quality_restart_scan.cpp b/tests/photo_quality_restart_scan.cpp new file mode 100644 index 0000000..0299cd6 --- /dev/null +++ b/tests/photo_quality_restart_scan.cpp @@ -0,0 +1,350 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +namespace { + +constexpr size_t kGroupsPerCamera = 128; + +struct TaskIds { + uint64_t a6000{}; + uint64_t s21{}; +}; + +struct Campaign { + std::vector sources; + std::vector confirmations; +}; + +bool setup_runtime(Lardon3DAppState &state) { + state.hardware_profile.logical_cpu_count = 8; + state.hardware_profile.page_size_bytes = 4096; + state.hardware_profile.memory_total_bytes = UINT64_C(8) << 30; + std::snprintf(state.hardware_profile.cpu_architecture, + sizeof(state.hardware_profile.cpu_architecture), "restart-validation"); + Lardon3DResourcePolicy policy{}; + policy.maximum_cpu_load_ratio = 1.0; + policy.maximum_io_pressure_avg10 = 100.0; + policy.io_slot_capacity = 2; + state.resource_governor = + lardon3d_resource_governor_create(&state.hardware_profile, &policy); + state.task_queue = state.resource_governor + ? lardon3d_task_queue_create(state.resource_governor, 4) + : nullptr; + return state.task_queue != nullptr; +} + +std::string stem(const char *path) { + const char *name = std::strrchr(path, '/'); + name = name ? name + 1 : path; + const char *dot = std::strrchr(name, '.'); + return std::string(name, dot ? static_cast(dot - name) : std::strlen(name)); +} + +bool root(const char *path, Lardon3DAcquisitionCampaignRoot &result) { + std::error_code error; + const auto canonical = std::filesystem::canonical(path, error); + if (error || !std::filesystem::is_directory(canonical, error) || error) return false; + const std::string value = canonical.string(); + if (value.size() >= sizeof(result.path)) return false; + std::memcpy(result.path, value.c_str(), value.size() + 1u); + return true; +} + +bool discover_a6000(const char *raw, const char *jpeg, Campaign &campaign) { + Lardon3DAcquisitionCampaignRoot roots[2]{}; + if (!root(raw, roots[0]) || !root(jpeg, roots[1])) return false; + auto discovery = std::make_unique(); + if (lardon3d_acquisition_campaign_discover(roots, 2, discovery.get()) != + LARDON3D_ACQUISITION_CAMPAIGN_OK) + return false; + struct Pair { size_t raw = SIZE_MAX; size_t jpeg = SIZE_MAX; }; + std::map pairs; + for (size_t i = 0; i < discovery->source_count; ++i) { + Pair &pair = pairs[stem(discovery->sources[i].path)]; + if (discovery->sources[i].source_kind == LARDON3D_ACQUISITION_SOURCE_RAW) + pair.raw = i; + else if (discovery->sources[i].source_kind == LARDON3D_ACQUISITION_SOURCE_JPEG) + pair.jpeg = i; + } + std::vector selected; + for (const auto &entry : pairs) { + if (entry.second.raw == SIZE_MAX || entry.second.jpeg == SIZE_MAX) continue; + selected.push_back(entry.second); + if (selected.size() == kGroupsPerCamera) break; + } + if (selected.size() != kGroupsPerCamera) return false; + std::vector indices; + indices.reserve(selected.size() * 2u); + for (const Pair &pair : selected) { + indices.push_back(pair.raw); + indices.push_back(pair.jpeg); + } + std::sort(indices.begin(), indices.end()); + std::map remap; + for (size_t old_index : indices) { + remap.emplace(old_index, campaign.sources.size()); + campaign.sources.push_back(discovery->sources[old_index]); + } + for (const Pair &pair : selected) { + Lardon3DAcquisitionCampaignConfirmation confirmation{}; + confirmation.source_count = 2; + confirmation.source_indices[0] = remap[pair.raw]; + confirmation.source_indices[1] = remap[pair.jpeg]; + campaign.confirmations.push_back(confirmation); + } + return campaign.confirmations.size() == kGroupsPerCamera; +} + +bool discover_s21(const char *directory, Campaign &campaign) { + Lardon3DAcquisitionCampaignRoot source_root{}; + if (!root(directory, source_root)) return false; + auto discovery = std::make_unique(); + if (lardon3d_acquisition_campaign_discover(&source_root, 1, discovery.get()) != + LARDON3D_ACQUISITION_CAMPAIGN_OK) + return false; + for (size_t i = 0; i < discovery->source_count && + campaign.confirmations.size() < kGroupsPerCamera; ++i) { + if (discovery->sources[i].source_kind != LARDON3D_ACQUISITION_SOURCE_JPEG) continue; + Lardon3DAcquisitionCampaignConfirmation confirmation{}; + confirmation.source_count = 1; + confirmation.source_indices[0] = campaign.sources.size(); + campaign.sources.push_back(discovery->sources[i]); + campaign.confirmations.push_back(confirmation); + } + return campaign.confirmations.size() == kGroupsPerCamera; +} + +bool wait_cursors_then_pause(Lardon3DAppState &state, uint64_t first_id, uint64_t second_id) { + std::vector first_request(LARDON3D_PHOTO_QUALITY_TASK_REQUEST_MAX_BYTES); + bool first_requested = false; + for (size_t attempt = 0; attempt < 10000; ++attempt) { + Lardon3DProjectDbPhotoQualityTask first{}; + if (!first_requested && + lardon3d_project_db_load_photo_quality_task( + state.project_db, first_id, first_request.data(), first_request.size(), &first) == + LARDON3D_PROJECT_DB_OK && first.next_group_id >= 2 && + first.next_group_id <= first.group_count) { + first_requested = lardon3d_task_queue_pause(state.task_queue, first_id); + } + if (first_requested) { + Lardon3DTaskSnapshot first_snapshot{}, second_snapshot{}; + if (lardon3d_task_queue_get(state.task_queue, first_id, &first_snapshot) && + lardon3d_task_queue_get(state.task_queue, second_id, &second_snapshot) && + first_snapshot.state == TASK_PAUSED && second_snapshot.state == TASK_PENDING) + return true; + } + usleep(1000); + } + Lardon3DTaskSnapshot first_snapshot{}, second_snapshot{}; + Lardon3DProjectDbPhotoQualityTask first{}; + (void)lardon3d_task_queue_get(state.task_queue, first_id, &first_snapshot); + (void)lardon3d_task_queue_get(state.task_queue, second_id, &second_snapshot); + (void)lardon3d_project_db_load_photo_quality_task( + state.project_db, first_id, first_request.data(), first_request.size(), &first); + std::fprintf(stderr, + "pause timeout: A state=%s cursor=%u requested=%d message=%s; " + "S state=%s message=%s\n", + lardon3d_task_state_name(first_snapshot.state), first.next_group_id, + first_requested, first_snapshot.message, + lardon3d_task_state_name(second_snapshot.state), second_snapshot.message); + return false; +} + +bool wait_completed(Lardon3DAppState &state, uint64_t task_id) { + for (size_t attempt = 0; attempt < 180000; ++attempt) { + Lardon3DTaskSnapshot snapshot{}; + if (lardon3d_task_queue_get(state.task_queue, task_id, &snapshot)) { + if (snapshot.state == TASK_COMPLETED) return true; + if (snapshot.state == TASK_FAILED || snapshot.state == TASK_CANCELLED) return false; + } + usleep(10000); + } + return false; +} + +bool enqueue_campaign(Lardon3DAppState &state, uint64_t scanset_id, + const Campaign &campaign, uint64_t &task_id) { + Lardon3DPhotoQualityTaskRequest request{ + campaign.sources.data(), campaign.sources.size(), campaign.confirmations.data(), + campaign.confirmations.size()}; + Lardon3DAcquisitionCampaignPlan plan{}; + if (lardon3d_acquisition_campaign_plan( + request.sources, request.source_count, request.confirmations, + request.confirmation_count, &plan) != LARDON3D_ACQUISITION_CAMPAIGN_OK) { + std::fprintf(stderr, "quality request planning failed\n"); + return false; + } + size_t encoded_size = 0; + if (!lardon3d_photo_quality_request_encode(&request, nullptr, 0, &encoded_size)) { + std::fprintf(stderr, "quality request encoding probe failed\n"); + return false; + } + Lardon3DTask *task = + lardon3d_project_create_photo_quality_task(&state, scanset_id, &request, &task_id); + if (!task) { + std::fprintf(stderr, "quality durable Task creation failed: encoded=%zu\n", encoded_size); + return false; + } + if (!lardon3d_task_queue_add(state.task_queue, task, nullptr)) { + std::fprintf(stderr, "quality Queue transfer failed\n"); + lardon3d_task_destroy(task); + return false; + } + return true; +} + +int child_run(int output, const Campaign &a6000, const Campaign &s21) { + Lardon3DAppState state{}; + lardon3d_app_state_init(&state); + if (!setup_runtime(state) || !lardon3d_project_create(&state, "Quality Restart")) return 10; + Lardon3DProjectDbScanSet a6000_scanset{}, s21_scanset{}; + if (lardon3d_project_db_create_scanset(state.project_db, "A6000", &a6000_scanset) != + LARDON3D_PROJECT_DB_OK || + lardon3d_project_db_create_scanset(state.project_db, "S21 FE", &s21_scanset) != + LARDON3D_PROJECT_DB_OK) + return 11; + TaskIds ids{}; + if (!enqueue_campaign(state, a6000_scanset.scanset_id, a6000, ids.a6000)) { + std::fprintf(stderr, "A6000 quality enqueue failed: %s\n", state.status_message); + return 12; + } + if (!enqueue_campaign(state, s21_scanset.scanset_id, s21, ids.s21)) { + std::fprintf(stderr, "S21 quality enqueue failed: %s\n", state.status_message); + return 12; + } + if (!wait_cursors_then_pause(state, ids.a6000, ids.s21)) + return 13; + /* Overrides are operator decisions independent of measured metrics. They are + * intentionally durable before this validation process dies. */ + if (lardon3d_project_db_set_photo_quality_override( + state.project_db, ids.a6000, 1, LARDON3D_PHOTO_QUALITY_OVERRIDE_INCLUDE) != + LARDON3D_PROJECT_DB_OK || + lardon3d_project_db_set_photo_quality_override( + state.project_db, ids.a6000, 2, LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE) != + LARDON3D_PROJECT_DB_OK) + return 14; + if (write(output, &ids, sizeof(ids)) != static_cast(sizeof(ids))) return 15; + /* Deliberately bypass Task/Queue destruction. The parent must recover only + * from production checkpoints and typed cursors, as after process loss. */ + return 0; +} + +bool verify_override(Lardon3DProjectDb *database, uint64_t task_id, uint32_t group_id, + Lardon3DPhotoQualityOverride expected) { + Lardon3DProjectDbPhotoQualityResult result{}; + return lardon3d_project_db_load_photo_quality_result(database, task_id, group_id, &result) == + LARDON3D_PROJECT_DB_OK && + /* Overrides are valid for every measured recommendation, including the + * deliberate non-GOOD A6000 target used by this restart smoke test. */ + result.override_value == expected; +} + +} // namespace + +extern "C" const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_production(void) { + static const Lardon3DTaskKindDescriptor descriptors[] = {{ + LARDON3D_PHOTO_QUALITY_TASK_KIND, LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION, + lardon3d_photo_quality_task_reconstruct}}; + static const Lardon3DTaskKindRegistry registry{descriptors, 1}; + return ®istry; +} + +int main(int argc, char **argv) { + if (argc != 4) { + std::fprintf(stderr, "Usage: %s A6000_RAW_DIR A6000_JPEG_DIR S21_DIR\n", argv[0]); + return 2; + } + Campaign a6000, s21; + if (!discover_a6000(argv[1], argv[2], a6000) || !discover_s21(argv[3], s21)) { + std::fprintf(stderr, "real corpus discovery or bounded grouping failed\n"); + return 1; + } + char project_root[] = "/tmp/lardon3d-quality-restart-XXXXXX"; + if (!mkdtemp(project_root) || setenv("LARDON3D_PROJECTS_ROOT", project_root, 1) != 0) + return 1; + int descriptors[2]{}; + if (pipe(descriptors) != 0) return 1; + const pid_t child = fork(); + if (child < 0) return 1; + if (child == 0) { + close(descriptors[0]); + const int result = child_run(descriptors[1], a6000, s21); + close(descriptors[1]); + _exit(result); + } + close(descriptors[1]); + TaskIds ids{}; + const ssize_t read_size = read(descriptors[0], &ids, sizeof(ids)); + close(descriptors[0]); + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || WEXITSTATUS(status) != 0 || + read_size != static_cast(sizeof(ids))) { + std::fprintf(stderr, "restart fixture child failed: status=%d read=%zd\n", status, read_size); + return 1; + } + + Lardon3DAppState state{}; + lardon3d_app_state_init(&state); + if (!setup_runtime(state) || !lardon3d_project_open(&state, "Quality Restart")) return 1; + Lardon3DProjectRecoverySummary recovery{}; + if (!lardon3d_project_last_recovery_summary(&state, &recovery) || recovery.inspected != 2 || + recovery.resumed != 2 || recovery.skipped != 0 || recovery.failed != 0 || + !wait_completed(state, ids.a6000) || !wait_completed(state, ids.s21)) + return 1; + if (lardon3d_project_db_set_photo_quality_override( + state.project_db, ids.s21, 1, LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE) != + LARDON3D_PROJECT_DB_OK || + lardon3d_project_db_set_photo_quality_override( + state.project_db, ids.s21, 2, LARDON3D_PHOTO_QUALITY_OVERRIDE_INCLUDE) != + LARDON3D_PROJECT_DB_OK) + return 1; + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = nullptr; + lardon3d_project_close(&state); + state.task_queue = lardon3d_task_queue_create(state.resource_governor, 4); + if (!state.task_queue || !lardon3d_project_open(&state, "Quality Restart")) return 1; + Lardon3DProjectRecoverySummary completed_reopen{}; + if (!lardon3d_project_last_recovery_summary(&state, &completed_reopen) || + completed_reopen.inspected != 0 || completed_reopen.resumed != 0) + return 1; + if (!verify_override(state.project_db, ids.a6000, 1, + LARDON3D_PHOTO_QUALITY_OVERRIDE_INCLUDE) || + !verify_override(state.project_db, ids.a6000, 2, + LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE) || + !verify_override(state.project_db, ids.s21, 1, + LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE) || + !verify_override(state.project_db, ids.s21, 2, + LARDON3D_PHOTO_QUALITY_OVERRIDE_INCLUDE) || + lardon3d_resource_governor_reservation_count(state.resource_governor) != 0) + return 1; + + std::printf("QUALITY_RESTART_RESULT=PASS A6000_GROUPS=%zu S21_GROUPS=%zu " + "RECOVERY_INSPECTED=%zu RECOVERY_RESUMED=%zu INCLUDE_EXCLUDE=PERSISTED\n", + a6000.confirmations.size(), s21.confirmations.size(), recovery.inspected, + recovery.resumed); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = nullptr; + lardon3d_project_close(&state); + lardon3d_resource_governor_destroy(state.resource_governor); + std::error_code error; + std::filesystem::remove_all(project_root, error); + unsetenv("LARDON3D_PROJECTS_ROOT"); + return error ? 1 : 0; +} diff --git a/tests/test_geometric_verification.c b/tests/test_geometric_verification.c index 614ed46..bb7ac8d 100644 --- a/tests/test_geometric_verification.c +++ b/tests/test_geometric_verification.c @@ -202,7 +202,7 @@ static bool test_model_api(const char *path) { Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); Parents parents; CHECK(create_parents(database, &parents)); @@ -486,7 +486,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { 0)); CHECK(lardon3d_project_db_open(v11_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); Parents parents; CHECK(create_parents(database, &parents)); unsigned char fingerprint[32] = {0x91}; @@ -501,7 +501,8 @@ static bool test_migration(const char *v11_path, const char *failed_path) { migrated_result.geometric_verification_result_id; lardon3d_project_db_close(database); CHECK(query_integer( - v11_path, "SELECT value FROM metadata WHERE key='schema_version'", 20)); + v11_path, "SELECT value FROM metadata WHERE key='schema_version'", + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK( query_integer(v11_path, "SELECT count(*) FROM sqlite_master WHERE type='index' AND " @@ -530,7 +531,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { 0)); CHECK(lardon3d_project_db_open(failed_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); return true; } diff --git a/tests/test_geometric_verifier_task.c b/tests/test_geometric_verifier_task.c index 722d857..d2417f0 100644 --- a/tests/test_geometric_verifier_task.c +++ b/tests/test_geometric_verifier_task.c @@ -369,7 +369,7 @@ static bool run_task_test(void) { 0)); CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); CHECK(remove_tree(root)); return true; diff --git a/tests/test_match_result.c b/tests/test_match_result.c index cb56734..ad279ab 100644 --- a/tests/test_match_result.c +++ b/tests/test_match_result.c @@ -124,7 +124,7 @@ static bool run_test(void) { Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(database && lardon3d_project_db_schema_version(database) == 20); + CHECK(database && lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); Lardon3DProjectDbScanSet scanset; CHECK(lardon3d_project_db_create_scanset(database, "Match-test", &scanset) == @@ -501,7 +501,7 @@ static bool run_test(void) { CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); /* Verify persistence: load previously created results */ CHECK(lardon3d_project_db_load_match_result(database, first_id, &loaded) == @@ -544,11 +544,12 @@ static bool run_test(void) { v9_path, "SELECT value FROM metadata WHERE key='schema_version'", 9)); CHECK(lardon3d_project_db_open(v9_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v9_path, "SELECT value FROM metadata WHERE key='schema_version'", 20)); + v9_path, "SELECT value FROM metadata WHERE key='schema_version'", + LARDON3D_PROJECT_DB_SCHEMA_VERSION)); CHECK( query_integer(v9_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -576,7 +577,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v10_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); lardon3d_project_db_close(database); database = NULL; diff --git a/tests/test_matcher_task.c b/tests/test_matcher_task.c index 7e7954d..e057349 100644 --- a/tests/test_matcher_task.c +++ b/tests/test_matcher_task.c @@ -373,7 +373,8 @@ static bool run_test(void) { "name='matcher_tasks'", 0)); CHECK(reopen_runtime(&fixture)); - CHECK(lardon3d_project_db_schema_version(fixture.state.project_db) == 20); + CHECK(lardon3d_project_db_schema_version(fixture.state.project_db) == + LARDON3D_PROJECT_DB_SCHEMA_VERSION); CHECK( query_integer(database_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " diff --git a/tests/test_photo_quality.cpp b/tests/test_photo_quality.cpp new file mode 100644 index 0000000..026df90 --- /dev/null +++ b/tests/test_photo_quality.cpp @@ -0,0 +1,166 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + std::fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, \ + #condition); \ + return 1; \ + } \ + } while (0) + +static cv::Mat checker(int size) { + cv::Mat image(size, size, CV_8UC1); + for (int y = 0; y < size; ++y) + for (int x = 0; x < size; ++x) + image.at(y, x) = ((x / (size / 16)) + (y / (size / 16))) % 2 ? 220 : 30; + return image; +} + +int main() { + char directory_template[] = "/tmp/lardon3d-photo-quality-XXXXXX"; + char *directory = mkdtemp(directory_template); + CHECK(directory != nullptr); + const std::string sharp_path = std::string(directory) + "/sharp.jpg"; + const std::string blur_path = std::string(directory) + "/blur.jpg"; + const std::string large_path = std::string(directory) + "/large.jpg"; + const std::string white_path = std::string(directory) + "/white.jpg"; + const std::string oversized_path = std::string(directory) + "/oversized.jpg"; + const std::string truncated_oversized_path = + std::string(directory) + "/truncated-oversized.jpg"; + const std::string over_budget_oversized_path = + std::string(directory) + "/over-budget-oversized.jpg"; + const std::string malformed_path = std::string(directory) + "/malformed.jpg"; + + cv::Mat sharp = checker(512); + cv::Mat blurred; + cv::GaussianBlur(sharp, blurred, cv::Size(31, 31), 8.0); + cv::Mat large; + cv::resize(sharp, large, cv::Size(1024, 1024), 0.0, 0.0, cv::INTER_NEAREST); + cv::Mat white(512, 512, CV_8UC1, cv::Scalar(255)); + CHECK(cv::imwrite(sharp_path, sharp)); + CHECK(cv::imwrite(blur_path, blurred)); + CHECK(cv::imwrite(large_path, large)); + CHECK(cv::imwrite(white_path, white)); + cv::Mat oversized(2, LARDON3D_PHOTO_QUALITY_JPEG_MAX_DIMENSION + 1, CV_8UC1, + cv::Scalar(127)); + CHECK(cv::imwrite(oversized_path, oversized)); + { + std::FILE *over_budget = std::fopen(over_budget_oversized_path.c_str(), "wb"); + CHECK(over_budget != nullptr); + const unsigned char header[] = { + 0xff, 0xd8, // SOI + 0xff, 0xc0, 0x00, 0x08, 0x08, 0x00, 0x02, 0x20, 0x01, 0x00, + 0xff, 0xda, 0x00, 0x02 // SOS with an empty synthetic header. + }; + CHECK(std::fwrite(header, 1, sizeof(header), over_budget) == sizeof(header)); + unsigned char entropy[64 * 1024]; + std::memset(entropy, 0x11, sizeof(entropy)); + /* The validator must reject finite-but-excessive structural work without + * allocating the oversized raster or treating the parser cap as science. */ + for (size_t i = 0; i < 1025; ++i) + CHECK(std::fwrite(entropy, 1, sizeof(entropy), over_budget) == sizeof(entropy)); + CHECK(std::fclose(over_budget) == 0); + } + { + std::FILE *source = std::fopen(oversized_path.c_str(), "rb"); + std::FILE *truncated = std::fopen(truncated_oversized_path.c_str(), "wb"); + CHECK(source != nullptr && truncated != nullptr); + unsigned char bytes[128]; + const size_t count = std::fread(bytes, 1, sizeof(bytes), source); + CHECK(count > 16 && std::fwrite(bytes, 1, count, truncated) == count); + CHECK(std::fclose(source) == 0 && std::fclose(truncated) == 0); + } + { + std::FILE *malformed = std::fopen(malformed_path.c_str(), "wb"); + CHECK(malformed != nullptr); + CHECK(std::fwrite("not-a-jpeg", 1, 10, malformed) == 10); + CHECK(std::fclose(malformed) == 0); + } + + Lardon3DPhotoQualityMetrics sharp_metrics{}; + Lardon3DPhotoQualityMetrics blur_metrics{}; + Lardon3DPhotoQualityMetrics large_metrics{}; + Lardon3DPhotoQualityMetrics white_metrics{}; + CHECK(lardon3d_photo_quality_analyze_jpeg(sharp_path.c_str(), &sharp_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_OK); + CHECK(lardon3d_photo_quality_analyze_jpeg(blur_path.c_str(), &blur_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_OK); + CHECK(sharp_metrics.sharpness_normalized > blur_metrics.sharpness_normalized * 4.0); + CHECK(lardon3d_photo_quality_analyze_jpeg(white_path.c_str(), &white_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_OK); + CHECK(white_metrics.clipped_white_fraction > 0.99); + CHECK(white_metrics.recommendation == LARDON3D_PHOTO_QUALITY_REJECT); + + CHECK(lardon3d_photo_quality_analyze_jpeg(large_path.c_str(), &large_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_OK); + const double scale_ratio = sharp_metrics.sharpness_normalized / + large_metrics.sharpness_normalized; + CHECK(scale_ratio > 0.8 && scale_ratio < 1.25); + + /* An oversized SOF becomes pending evidence before OpenCV can allocate its + * raster. The operational ceiling is neither a decode error nor rejection. */ + Lardon3DPhotoQualityMetrics oversized_metrics{}; + CHECK(lardon3d_photo_quality_analyze_jpeg(oversized_path.c_str(), &oversized_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE); + CHECK(oversized_metrics.recommendation == LARDON3D_PHOTO_QUALITY_SUSPECT); + CHECK(std::strstr(oversized_metrics.reasons, "DIMENSIONS_EXCEED") != nullptr); + CHECK(!lardon3d_photo_quality_effective_include(oversized_metrics.recommendation, + LARDON3D_PHOTO_QUALITY_OVERRIDE_NONE)); + + /* An oversized SOF does not suppress structural errors after that header. */ + Lardon3DPhotoQualityMetrics truncated_oversized_metrics{}; + CHECK(lardon3d_photo_quality_analyze_jpeg(truncated_oversized_path.c_str(), + &truncated_oversized_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR); + CHECK(std::strcmp(truncated_oversized_metrics.reasons, "JPEG_DECODE_ERROR") == 0); + + Lardon3DPhotoQualityMetrics over_budget_oversized_metrics{}; + CHECK(lardon3d_photo_quality_analyze_jpeg(over_budget_oversized_path.c_str(), + &over_budget_oversized_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR); + CHECK(std::strcmp(over_budget_oversized_metrics.reasons, "JPEG_DECODE_ERROR") == 0); + + /* Malformed input is a decode error and remains observably distinct from a + * structurally valid JPEG rejected only by the pre-allocation proxy bound. */ + Lardon3DPhotoQualityMetrics malformed_metrics{}; + CHECK(lardon3d_photo_quality_analyze_jpeg(malformed_path.c_str(), &malformed_metrics) == + LARDON3D_PHOTO_QUALITY_METRIC_DECODE_ERROR); + CHECK(malformed_metrics.recommendation == LARDON3D_PHOTO_QUALITY_REJECT); + CHECK(std::strcmp(malformed_metrics.reasons, "JPEG_DECODE_ERROR") == 0); + + /* Reversing analysis order must not leak state or alter deterministic output. */ + Lardon3DPhotoQualityMetrics second_blur{}; + Lardon3DPhotoQualityMetrics second_sharp{}; + CHECK(lardon3d_photo_quality_analyze_jpeg(blur_path.c_str(), &second_blur) == + LARDON3D_PHOTO_QUALITY_METRIC_OK); + CHECK(lardon3d_photo_quality_analyze_jpeg(sharp_path.c_str(), &second_sharp) == + LARDON3D_PHOTO_QUALITY_METRIC_OK); + CHECK(second_blur.sharpness_normalized == blur_metrics.sharpness_normalized); + CHECK(second_sharp.sharpness_normalized == sharp_metrics.sharpness_normalized); + + Lardon3DPhotoQualityMetrics raw_only{}; + lardon3d_photo_quality_raw_only(&raw_only); + CHECK(raw_only.status == LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE); + CHECK(std::strstr(raw_only.reasons, "REQUIRES_JPEG_PROXY") != nullptr); + CHECK(!lardon3d_photo_quality_effective_include(LARDON3D_PHOTO_QUALITY_SUSPECT, + LARDON3D_PHOTO_QUALITY_OVERRIDE_NONE)); + CHECK(lardon3d_photo_quality_effective_include(LARDON3D_PHOTO_QUALITY_REJECT, + LARDON3D_PHOTO_QUALITY_OVERRIDE_INCLUDE)); + CHECK(!lardon3d_photo_quality_effective_include(LARDON3D_PHOTO_QUALITY_GOOD, + LARDON3D_PHOTO_QUALITY_OVERRIDE_EXCLUDE)); + + std::filesystem::remove_all(directory); + return 0; +} diff --git a/tests/test_photo_quality_task.cpp b/tests/test_photo_quality_task.cpp new file mode 100644 index 0000000..841640f --- /dev/null +++ b/tests/test_photo_quality_task.cpp @@ -0,0 +1,322 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +extern "C" { +#include +#include +#include +} + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + std::fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, \ + #condition); \ + return 1; \ + } \ + } while (0) + +extern "C" const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_production(void) { + return nullptr; +} + +static bool wait_terminal(Lardon3DTaskQueue *queue, uint64_t task_id, + Lardon3DTaskSnapshot *snapshot) { + for (size_t attempt = 0; attempt < 500; ++attempt) { + if (lardon3d_task_queue_get(queue, task_id, snapshot) && + (snapshot->state == TASK_COMPLETED || snapshot->state == TASK_FAILED || + snapshot->state == TASK_CANCELLED)) + return true; + usleep(10000); + } + return false; +} + +static void source(Lardon3DAcquisitionCampaignSource *value, const std::string &path, + Lardon3DAcquisitionSourceKind kind) { + std::snprintf(value->path, sizeof(value->path), "%s", path.c_str()); + value->source_kind = value->metadata.source_kind = kind; + value->metadata_result = LARDON3D_ACQUISITION_OK; + value->metadata.policy_version = LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION; +} + +int main() { + char directory_template[] = "/tmp/lardon3d-photo-quality-task-XXXXXX"; + char *directory = mkdtemp(directory_template); + CHECK(directory != nullptr); + const std::string root(directory); + const std::string database_path = root + "/project.lardon3d"; + const std::string jpeg_path = root + "/1-pair.jpg"; + CHECK(std::filesystem::create_directories(root + "/.lardon3d/checkpoints")); + cv::Mat image(128, 128, CV_8UC1); + for (int y = 0; y < image.rows; ++y) + for (int x = 0; x < image.cols; ++x) + image.at(y, x) = ((x / 8) + (y / 8)) % 2 ? 220 : 30; + CHECK(cv::imwrite(jpeg_path, image)); + + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{}; + Lardon3DProjectDb *database = nullptr; + CHECK(lardon3d_project_db_open(database_path.c_str(), &database, error) == + LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbScanSet scanset{}; + CHECK(lardon3d_project_db_create_scanset(database, "quality", &scanset) == + LARDON3D_PROJECT_DB_OK); + Lardon3DAppState state{}; + lardon3d_app_state_init(&state); + state.project_loaded = true; + state.project_db = database; + std::snprintf(state.project_path, sizeof(state.project_path), "%s", root.c_str()); + state.hardware_profile.logical_cpu_count = 8; + state.hardware_profile.page_size_bytes = 4096; + state.hardware_profile.memory_total_bytes = UINT64_C(8) << 30; + std::snprintf(state.hardware_profile.cpu_architecture, + sizeof(state.hardware_profile.cpu_architecture), "test"); + Lardon3DResourcePolicy policy{}; + policy.maximum_cpu_load_ratio = 1.0; + policy.maximum_io_pressure_avg10 = 100.0; + policy.io_slot_capacity = 2; + state.resource_governor = + lardon3d_resource_governor_create(&state.hardware_profile, &policy); + state.task_queue = state.resource_governor + ? lardon3d_task_queue_create(state.resource_governor, 4) + : nullptr; + CHECK(state.task_queue != nullptr); + + Lardon3DAcquisitionCampaignSource sources[3]{}; + source(&sources[0], root + "/0-pair.arw", LARDON3D_ACQUISITION_SOURCE_RAW); + source(&sources[1], jpeg_path, LARDON3D_ACQUISITION_SOURCE_JPEG); + source(&sources[2], root + "/2-only.arw", LARDON3D_ACQUISITION_SOURCE_RAW); + Lardon3DAcquisitionCampaignConfirmation pair{}; + pair.source_count = 2; + pair.source_indices[0] = 0; + pair.source_indices[1] = 1; + Lardon3DPhotoQualityTaskRequest request{sources, 3, &pair, 1}; + Lardon3DAcquisitionCampaignPlan planned{}; + CHECK(lardon3d_acquisition_campaign_plan(sources, 3, &pair, 1, &planned) == + LARDON3D_ACQUISITION_CAMPAIGN_OK && + planned.group_count == 2); + size_t request_probe_size = 0; + CHECK(lardon3d_photo_quality_request_encode(&request, nullptr, 0, &request_probe_size)); + + uint64_t task_id = 0; + CHECK(lardon3d_project_enqueue_photo_quality(&state, scanset.scanset_id, &request, &task_id)); + Lardon3DTaskSnapshot terminal{}; + CHECK(wait_terminal(state.task_queue, task_id, &terminal)); + CHECK(terminal.state == TASK_COMPLETED && terminal.progress == 100); + Lardon3DProjectDbPhotoQualityResult paired{}, raw_only{}; + CHECK(lardon3d_project_db_load_photo_quality_result(database, task_id, 1, &paired) == + LARDON3D_PROJECT_DB_OK); + CHECK(paired.proxy_source_index == 1 && + paired.metrics.status == LARDON3D_PHOTO_QUALITY_METRIC_OK); + CHECK(paired.group_id == planned.groups[0].group_id); + CHECK(lardon3d_project_db_load_photo_quality_result(database, task_id, 2, &raw_only) == + LARDON3D_PROJECT_DB_OK); + CHECK(raw_only.proxy_source_index == UINT32_MAX && + raw_only.metrics.status == LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE && + std::strstr(raw_only.metrics.reasons, "REQUIRES_JPEG_PROXY") != nullptr); + Lardon3DProjectDbTask completed_generic{}; + CHECK(lardon3d_project_db_load_task(database, task_id, &completed_generic) == + LARDON3D_PROJECT_DB_OK && + completed_generic.sequence_count == 1); + std::vector corrupt_request(LARDON3D_PHOTO_QUALITY_TASK_REQUEST_MAX_BYTES); + Lardon3DProjectDbPhotoQualityTask completed_parameters{}; + CHECK(lardon3d_project_db_load_photo_quality_task(database, task_id, corrupt_request.data(), + corrupt_request.size(), &completed_parameters) == LARDON3D_PROJECT_DB_OK); + CHECK(completed_parameters.group_count == 2 && completed_parameters.next_group_id == 3); + CHECK(lardon3d_resource_governor_reservation_count(state.resource_governor) == 0); + + /* Corrupt values above the v21 operational group bound must be rejected + * while still 64-bit SQLite integers, before narrowing into public fields. */ + sqlite3 *corruptor = nullptr; + CHECK(sqlite3_open(database_path.c_str(), &corruptor) == SQLITE_OK); + CHECK(sqlite3_exec(corruptor, "PRAGMA ignore_check_constraints=ON", nullptr, nullptr, + nullptr) == SQLITE_OK); + const auto execute_corruption = [&](const std::string &assignment) { + const std::string sql = "UPDATE photo_quality_triage_tasks SET " + assignment + + " WHERE task_id=" + std::to_string(task_id); + return sqlite3_exec(corruptor, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK; + }; + Lardon3DProjectDbPhotoQualityTask corrupt_parameters{}; + for (const char *assignment : {"group_count=-1", "group_count=5000", "group_count=4294967296", + "next_group_id=0", "next_group_id=4", "next_group_id=1e20", + "scanset_id=-1"}) { + CHECK(execute_corruption(assignment)); + CHECK(lardon3d_project_db_load_photo_quality_task(database, task_id, corrupt_request.data(), + corrupt_request.size(), &corrupt_parameters) == LARDON3D_PROJECT_DB_CORRUPT); + CHECK(execute_corruption("scanset_id=" + std::to_string(scanset.scanset_id) + + ",group_count=2,next_group_id=3")); + } + + const auto execute_result_corruption = [&](const std::string &assignment) { + const std::string sql = "UPDATE photo_quality_triage_results SET " + assignment + + " WHERE task_id=" + std::to_string(task_id) + " AND group_id=1"; + return sqlite3_exec(corruptor, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK; + }; + for (const auto &values : std::vector>{ + {"proxy_source_index=-1", "proxy_source_index=1"}, + {"proxy_source_index=4294967296", "proxy_source_index=1"}, + {"proxy_source_index=1e20", "proxy_source_index=1"}, + {"metrics_version=-1", "metrics_version=1"}, + {"metric_status=-1", "metric_status=0"}, + {"recommendation=4", "recommendation=1"}, + {"human_override=3", "human_override=0"}, + {"decoded_width=-1", "decoded_width=128"}, + {"analysis_width=4294967296", "analysis_width=128"}, + {"analysis_height=0", "analysis_height=128"}, + {"sharpness_normalized=-1.0", + "sharpness_normalized=" + std::to_string(paired.metrics.sharpness_normalized)}, + {"clipped_white_fraction=2.0", + "clipped_white_fraction=" + std::to_string(paired.metrics.clipped_white_fraction)}}) { + CHECK(execute_result_corruption(values.first)); + Lardon3DProjectDbPhotoQualityResult corrupt_result{}; + CHECK(lardon3d_project_db_load_photo_quality_result(database, task_id, 1, &corrupt_result) == + LARDON3D_PROJECT_DB_CORRUPT); + CHECK(execute_result_corruption(values.second)); + } + /* Result identity must belong to the typed plan and precede its durable + * cursor; otherwise publication would expose uncompleted plan state. */ + CHECK(execute_result_corruption("group_id=3")); + Lardon3DProjectDbPhotoQualityResult corrupt_plan_result{}; + CHECK(lardon3d_project_db_load_photo_quality_result(database, task_id, 3, + &corrupt_plan_result) == LARDON3D_PROJECT_DB_CORRUPT); + CHECK(sqlite3_exec(corruptor, + ("UPDATE photo_quality_triage_results SET group_id=1 WHERE task_id=" + + std::to_string(task_id) + " AND group_id=3").c_str(), nullptr, nullptr, nullptr) == + SQLITE_OK); + CHECK(execute_corruption("next_group_id=1")); + CHECK(lardon3d_project_db_load_photo_quality_result(database, task_id, 1, + &corrupt_plan_result) == LARDON3D_PROJECT_DB_CORRUPT); + CHECK(execute_corruption("next_group_id=3")); + sqlite3_close(corruptor); + + uint64_t cancelled_id = 0; + Lardon3DTask *cancelled = lardon3d_project_create_photo_quality_task( + &state, scanset.scanset_id, &request, &cancelled_id); + CHECK(cancelled != nullptr); + Lardon3DResourceEstimate quality_estimate{}; + CHECK(lardon3d_task_resource_estimate(cancelled, &quality_estimate)); + /* Admission includes the retained durable context in addition to the + * documented 20 MiB one-group analyzer working set. */ + CHECK(quality_estimate.memory_fixed_bytes > UINT64_C(20) * 1024u * 1024u); + CHECK(quality_estimate.memory_bytes_per_item == 0); + lardon3d_task_request_cancel(cancelled); + CHECK(lardon3d_task_queue_add(state.task_queue, cancelled, nullptr)); + CHECK(wait_terminal(state.task_queue, cancelled_id, &terminal)); + CHECK(terminal.state == TASK_CANCELLED); + Lardon3DProjectDbPhotoQualityResult absent{}; + CHECK(raw_only.group_id == planned.groups[1].group_id); + CHECK(lardon3d_project_db_load_photo_quality_result(database, cancelled_id, 1, &absent) == + LARDON3D_PROJECT_DB_NOT_FOUND); + + /* Seed the documented crash shape: plan group 1 and next group ID 2 are durable while + * generic progress still lags. Registry recovery must begin at plan group 2 and + * must neither re-analyze nor replace the already published pair result. */ + size_t encoded_size = 0; + CHECK(lardon3d_photo_quality_request_encode(&request, nullptr, 0, &encoded_size)); + std::vector encoded(encoded_size); + CHECK(lardon3d_photo_quality_request_encode(&request, encoded.data(), encoded.size(), + &encoded_size)); + uint64_t restart_id = 0; + CHECK(lardon3d_project_db_allocate_task_id(database, &restart_id) == LARDON3D_PROJECT_DB_OK); + Lardon3DTaskDurableSnapshot durable{}; + durable.id = restart_id; + std::snprintf(durable.name, sizeof(durable.name), "quality restart"); + durable.saved_state = durable.recovery_state = TASK_PENDING; + Lardon3DProjectDbPhotoQualityTask parameters{restart_id, scanset.scanset_id, 1, 2, + encoded.data(), encoded.size()}; + CHECK(lardon3d_project_db_record_photo_quality_task( + database, &durable, LARDON3D_PHOTO_QUALITY_TASK_KIND, + LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION, nullptr, ¶meters, 1) == + LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbPhotoQualityResult sentinel = paired; + sentinel.task_id = restart_id; + sentinel.metrics.sharpness_raw = 12345.0; + CHECK(lardon3d_project_db_record_photo_quality_result(database, &sentinel, 2) == + LARDON3D_PROJECT_DB_OK); + /* Exact replay after the atomic cursor advance converges without replacing + * identity or creating another result row. */ + CHECK(lardon3d_project_db_record_photo_quality_result(database, &sentinel, 2) == + LARDON3D_PROJECT_DB_OK); + sqlite3 *replay_reader = nullptr; + CHECK(sqlite3_open(database_path.c_str(), &replay_reader) == SQLITE_OK); + sqlite3_stmt *count_statement = nullptr; + CHECK(sqlite3_prepare_v2(replay_reader, + "SELECT COUNT(*) FROM photo_quality_triage_results WHERE task_id=?1 AND group_id=1", + -1, &count_statement, nullptr) == SQLITE_OK); + CHECK(sqlite3_bind_int64(count_statement, 1, (sqlite3_int64)restart_id) == SQLITE_OK); + CHECK(sqlite3_step(count_statement) == SQLITE_ROW && + sqlite3_column_int64(count_statement, 0) == 1); + sqlite3_finalize(count_statement); + Lardon3DProjectDbPhotoQualityResult conflicting = sentinel; + conflicting.metrics.sharpness_raw += 1.0; + CHECK(lardon3d_project_db_record_photo_quality_result(database, &conflicting, 2) == + LARDON3D_PROJECT_DB_CONSTRAINT); + CHECK(sqlite3_exec(replay_reader, + ("UPDATE photo_quality_triage_tasks SET next_group_id=3 WHERE task_id=" + + std::to_string(restart_id)).c_str(), nullptr, nullptr, nullptr) == SQLITE_OK); + CHECK(lardon3d_project_db_record_photo_quality_result(database, &sentinel, 2) == + LARDON3D_PROJECT_DB_CONSTRAINT); + CHECK(sqlite3_exec(replay_reader, + ("UPDATE photo_quality_triage_tasks SET next_group_id=2 WHERE task_id=" + + std::to_string(restart_id)).c_str(), nullptr, nullptr, nullptr) == SQLITE_OK); + sqlite3_close(replay_reader); + Lardon3DProjectDbPhotoQualityResult replay_retained{}; + CHECK(lardon3d_project_db_load_photo_quality_result(database, restart_id, 1, + &replay_retained) == LARDON3D_PROJECT_DB_OK && + replay_retained.metrics.sharpness_raw == sentinel.metrics.sharpness_raw); + Lardon3DProjectDbTask generic{}; + CHECK(lardon3d_project_db_load_task(database, restart_id, &generic) == LARDON3D_PROJECT_DB_OK); + Lardon3DTaskDurableSnapshot restart_snapshot{}; + restart_snapshot.id = generic.task_id; + std::memcpy(restart_snapshot.name, generic.name, sizeof(restart_snapshot.name)); + restart_snapshot.progress = generic.progress; + restart_snapshot.saved_state = generic.saved_state; + restart_snapshot.recovery_state = generic.recovery_state; + restart_snapshot.started_at = generic.started_at; + restart_snapshot.finished_at = generic.finished_at; + restart_snapshot.sequence_count = generic.sequence_count; + restart_snapshot.estimate = quality_estimate; + Lardon3DTaskReconstructionContext reconstruction{root.c_str(), database, + state.resource_governor, nullptr}; + const Lardon3DTaskKindDescriptor quality_descriptor = { + LARDON3D_PHOTO_QUALITY_TASK_KIND, LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION, + lardon3d_photo_quality_task_reconstruct}; + Lardon3DTaskKindRegistry quality_registry{}; + CHECK(lardon3d_task_kind_registry_init(&quality_registry, &quality_descriptor, 1)); + const Lardon3DTaskKindRegistry *registry = &quality_registry; + const Lardon3DTaskKindDescriptor *descriptor = nullptr; + CHECK(lardon3d_task_kind_registry_lookup(registry, LARDON3D_PHOTO_QUALITY_TASK_KIND, + LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION, + &descriptor) == LARDON3D_TASK_KIND_OK); + Lardon3DTask *restored = nullptr; + CHECK(lardon3d_task_kind_registry_restore(registry, LARDON3D_PHOTO_QUALITY_TASK_KIND, + LARDON3D_PHOTO_QUALITY_TASK_KIND_VERSION, + &restart_snapshot, &reconstruction, &restored) == + LARDON3D_TASK_KIND_OK && + restored != nullptr); + CHECK(lardon3d_task_queue_add(state.task_queue, restored, nullptr)); + CHECK(wait_terminal(state.task_queue, restart_id, &terminal)); + CHECK(terminal.state == TASK_COMPLETED); + Lardon3DProjectDbPhotoQualityResult retained{}, resumed_raw{}; + CHECK(lardon3d_project_db_load_photo_quality_result(database, restart_id, 1, &retained) == + LARDON3D_PROJECT_DB_OK && retained.metrics.sharpness_raw == 12345.0); + CHECK(lardon3d_project_db_load_photo_quality_result(database, restart_id, 2, &resumed_raw) == + LARDON3D_PROJECT_DB_OK && + resumed_raw.metrics.status == LARDON3D_PHOTO_QUALITY_METRIC_UNAVAILABLE); + + lardon3d_task_queue_destroy(state.task_queue); + lardon3d_resource_governor_destroy(state.resource_governor); + lardon3d_project_db_close(database); + std::filesystem::remove_all(root); + return 0; +} diff --git a/tests/test_project_db.c b/tests/test_project_db.c index 97c9294..5d7e9f5 100644 --- a/tests/test_project_db.c +++ b/tests/test_project_db.c @@ -116,7 +116,7 @@ static bool create_future_database(const char *path) { sqlite3_exec( connection, "CREATE TABLE metadata(key TEXT PRIMARY KEY,value INTEGER NOT NULL);" - "INSERT INTO metadata VALUES('schema_version',21);", + "INSERT INTO metadata VALUES('schema_version',22);", NULL, NULL, NULL) == SQLITE_OK; return sqlite3_close(connection) == SQLITE_OK && ok; } @@ -940,7 +940,7 @@ static bool run_test(void) { Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(database && lardon3d_project_db_schema_version(database) == 20); + CHECK(database && lardon3d_project_db_schema_version(database) == 21); bool legacy_pending = true; CHECK(lardon3d_project_db_legacy_catalog_pending(database, &legacy_pending) == LARDON3D_PROJECT_DB_OK && @@ -1499,7 +1499,7 @@ static bool run_test(void) { database = NULL; CHECK(query_integer(database_path, "SELECT value FROM metadata WHERE key='schema_version'", - 20)); + 21)); CHECK(query_integer(database_path, "SELECT count(*) FROM tasks WHERE task_id=1", 1)); @@ -1527,7 +1527,7 @@ static bool run_test(void) { "UPDATE metadata SET value=17 WHERE key='schema_version';")); CHECK(lardon3d_project_db_open(v17_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer(v17_path, @@ -1566,7 +1566,7 @@ static bool run_test(void) { "name='captures'", 0)); CHECK(lardon3d_project_db_open(failed_v19_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; CHECK(execute_test_sql( @@ -1618,7 +1618,7 @@ static bool run_test(void) { CHECK(create_v1_database(legacy_path)); CHECK(lardon3d_project_db_open(legacy_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); CHECK(lardon3d_project_db_get_project(database, &loaded_project) == LARDON3D_PROJECT_DB_OK && strcmp(loaded_project.stable_id, "legacy-project") == 0); @@ -1633,7 +1633,7 @@ static bool run_test(void) { database = NULL; CHECK(query_integer(legacy_path, "SELECT value FROM metadata WHERE key='schema_version'", - 20)); + 21)); CHECK(create_v1_database(failed_migration_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V2", "1", 1) == 0); @@ -1664,7 +1664,7 @@ static bool run_test(void) { lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v2_path, "SELECT value FROM metadata WHERE key='schema_version'", 20)); + v2_path, "SELECT value FROM metadata WHERE key='schema_version'", 21)); CHECK(create_v2_database(failed_v3_migration_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V3", "1", 1) == 0); @@ -1698,7 +1698,7 @@ static bool run_test(void) { lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v3_path, "SELECT value FROM metadata WHERE key='schema_version'", 20)); + v3_path, "SELECT value FROM metadata WHERE key='schema_version'", 21)); CHECK(create_v3_database(failed_v4_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V4", "1", 1) == 0); @@ -1720,7 +1720,7 @@ static bool run_test(void) { fprintf(stderr, "Migration v4 (%d): %s\n", (int)v4_result, error); } CHECK(v4_result == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); CHECK(lardon3d_project_db_load_task(database, 9, &task) == LARDON3D_PROJECT_DB_OK); CHECK(lardon3d_project_db_load_artifact(database, "legacy-artifact", @@ -1775,7 +1775,7 @@ static bool run_test(void) { error); } CHECK(retry_v7 == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; @@ -1785,7 +1785,7 @@ static bool run_test(void) { 5)); CHECK(lardon3d_project_db_open(direct_v5_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; @@ -1794,11 +1794,11 @@ static bool run_test(void) { v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 7)); CHECK(lardon3d_project_db_open(v8_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 20)); + v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 21)); CHECK(create_v7_database(failed_v8_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V8", "1", 1) == 0); @@ -1815,7 +1815,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v8_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; @@ -1834,11 +1834,11 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(v10_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v10_path, "SELECT value FROM metadata WHERE key='schema_version'", 20)); + v10_path, "SELECT value FROM metadata WHERE key='schema_version'", 21)); CHECK( query_integer(v10_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -1865,7 +1865,7 @@ static bool run_test(void) { 1)); CHECK(lardon3d_project_db_open(failed_v11_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; @@ -1879,11 +1879,11 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(v13_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v13_path, "SELECT value FROM metadata WHERE key='schema_version'", 20)); + v13_path, "SELECT value FROM metadata WHERE key='schema_version'", 21)); CHECK( query_integer(v13_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -1906,7 +1906,7 @@ static bool run_test(void) { true_v14_path); } CHECK(true_v14_result == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; CHECK( @@ -2021,7 +2021,7 @@ static bool run_test(void) { if (true_v15_result != LARDON3D_PROJECT_DB_OK) fprintf(stderr, "true v15 upgrade: %d %s\n", true_v15_result, error); CHECK(true_v15_result == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); Lardon3DProjectDbProject migrated_project; CHECK(lardon3d_project_db_get_project(database, &migrated_project) == LARDON3D_PROJECT_DB_OK && @@ -2067,7 +2067,7 @@ static bool run_test(void) { database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; CHECK(schema_compare(database_path, true_v15_path, true)); @@ -2102,7 +2102,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(true_v15_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; @@ -2121,7 +2121,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v14_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; @@ -2140,7 +2140,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v15_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 20); + lardon3d_project_db_schema_version(database) == 21); lardon3d_project_db_close(database); database = NULL; diff --git a/tests/test_sparse_sfm_model.c b/tests/test_sparse_sfm_model.c index 976dbd4..191f548 100644 --- a/tests/test_sparse_sfm_model.c +++ b/tests/test_sparse_sfm_model.c @@ -123,7 +123,7 @@ static bool run_test(void) { Lardon3DProjectDb *db = NULL; char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; CHECK(lardon3d_project_db_open(path, &db, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(db) == 20); + CHECK(lardon3d_project_db_schema_version(db) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); Lardon3DProjectDbScanSet scanset; CHECK(lardon3d_project_db_create_scanset(db, "Sparse model", &scanset) == diff --git a/tests/test_sparse_sfm_payload.cpp b/tests/test_sparse_sfm_payload.cpp index d3d86c4..870a52f 100644 --- a/tests/test_sparse_sfm_payload.cpp +++ b/tests/test_sparse_sfm_payload.cpp @@ -66,7 +66,7 @@ int main() { Lardon3DProjectDb *database = nullptr; char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{}; CHECK(lardon3d_project_db_open(path.c_str(), &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 20); + CHECK(lardon3d_project_db_schema_version(database) == LARDON3D_PROJECT_DB_SCHEMA_VERSION); sqlite3 *raw = nullptr; CHECK(sqlite3_open(path.c_str(), &raw) == SQLITE_OK);