diff --git a/README.md b/README.md index c0a2480..720b349 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ Lardon3D est un moteur de photogrammétrie Linux qui privilégie : - **Traçabilité** : historique des opérations et métriques - **Enrichissement progressif** : reconstruction incrémentale -Lardon3D ne vise pas simplement "dossier de photos → objet 3D", mais "ensemble progressif d'observations et de contraintes → reconstruction géométrique persistante, enrichissable et versionnable". +Lardon3D ne vise pas simplement "dossier de photos → objet 3D", mais un ensemble +progressif d'observations et de contraintes donnant une reconstruction géométrique +persistante, enrichissable et versionnable. ## État actuel @@ -27,8 +29,9 @@ Lardon3D ne vise pas simplement "dossier de photos → objet 3D", mais "ensemble - **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 v12** : tâches, catalogue, matching et résultats géométriques durables +- **Project Database v13** : résultats géométriques et tâche Geometric Verifier durables - **Geometric Verification Model v1** : identité, masque d'inliers et modèle 3×3 persistants +- **Geometric Verifier v1** : Fundamental USAC/MAGSAC, reprise et lots resource-aware - **Task Kind Registry** : identité métier durable et reconstruction runtime explicite - **Recovery projet** : reprise automatique sélective et bornée des imports récupérables - **Task Queue** : file FIFO avec sélection adaptative et backpressure @@ -113,6 +116,7 @@ Acquisitions - [Match Result](docs/architecture/match_result.md) - [Matcher](docs/architecture/matcher.md) - [Geometric Verification](docs/architecture/geometric_verification.md) +- [Geometric Verifier](docs/architecture/geometric_verifier.md) - [Backend Vulkan ORB](docs/architecture/vulkan_matcher.md) - [Viewer](docs/architecture/viewer.md) - [Revue des fondations](docs/architecture/foundation_review.md) @@ -154,12 +158,12 @@ Pour les changements sensibles à la mémoire ou à la concurrence, ajouter ASan Lardon3D est en développement actif. La persistance des tâches, le catalogue, le Feature Store multipasse, le Visual Index ORB, Candidate Pair Generator -et Matcher v1 sont implémentés. Geometric Verification Model v1 est implémenté ; -le Geometric Verifier reste planifié. Le runtime Feature + Matcher emploie des tâches -durables, de petits lots, le Resource Governor interactif et un hot path Vulkan -ORB exact avec fallback CPU. La feasibility Vulkan SIFT/RootSIFT a été rejetée ; -ces deux matchers restent sur OpenCV L2. DAG générique, -calcul de vérification géométrique, SfM et viewer restent des tickets séparés planifiés. +Matcher v1, Geometric Verification Model v1 et Geometric Verifier Fundamental v1 +sont implémentés. Le runtime Feature + Matcher + Verifier emploie des tâches durables, +de petits lots, le Resource Governor interactif et un hot path Vulkan ORB exact avec +fallback CPU. La feasibility Vulkan SIFT/RootSIFT a été rejetée ; ces deux matchers +restent sur OpenCV L2. DAG générique, Tracks, SfM et viewer restent des tickets +séparés planifiés. ## Licence diff --git a/docs/architecture/geometric_verifier.md b/docs/architecture/geometric_verifier.md new file mode 100644 index 0000000..13f0e29 --- /dev/null +++ b/docs/architecture/geometric_verifier.md @@ -0,0 +1,301 @@ +# Geometric Verifier v1 + +## Scope + +Ce document décrit l'exécution scientifique qui transforme un Match Result `MATCHED` en résultat +Fundamental `GEOMETRIC_REJECTED` ou `GEOMETRIC_VERIFIED`. Le contrat persistant reste défini par +[`geometric_verification.md`](geometric_verification.md). Tracks, pose, Essential, compétition +Homography, triangulation et SfM sont hors périmètre. + +## Inputs + +Le parent DB fournit les deux Feature Set IDs, le compte, le chemin, la taille et le SHA-256 du +Match File. Le reader Feature Store ouvre séparément chaque Feature Set validé et expose les +keypoints par plages d'au plus 256. Le verifier n'a besoin d'aucun descriptor : charger les blocs +ORB ou SIFT/RootSIFT serait inutile et est interdit dans le chemin normal. + +Les keypoints persistants portent des coordonnées `binary32`. `x/y` sont exprimés en pixels de +l'image exactement décodée par OpenCV lors de l'extraction, avec origine en haut à gauche et +positions subpixel possibles. Les dimensions décodées sont disponibles dans les métadonnées du +Feature File. + +## Fundamental matrix contract + +Le seul modèle v1 est une matrice Fundamental 3×3. Une sortie acceptée doit être unique, finie, +de norme non nulle et canonique avant publication. V1 ne projette pas la matrice vers le rang 2. + +## Input ordering + +L'entrée `i` de l'estimator correspond exactement à l'entrée `i` du Match File : +`feature_index_a` sélectionne le Feature Set A et `feature_index_b` le Feature Set B. Le Match +File impose déjà des indices A strictement croissants ; le verifier ne trie et ne filtre pas les +correspondances. Toute corruption d'index est une erreur d'exécution, jamais un rejet scientifique. + +## Coordinate representation + +Le stockage source reste `binary32`. Sur 1024 points, bruit 0,75 px et 50 % d'outliers, Point2f et +Point2d ont produit le même masque et la même qualité, en 3,58 et 3,55 ms. La production convertit +vers Point2d pour rendre le calcul et la sortie binary64 explicites, pour 256 Kio au maximum. +Aucune mise à l'échelle par résolution ni conversion de repère n'est appliquée implicitement. + +## Algorithm candidates + +OpenCV 5 installé expose `FM_RANSAC`, `USAC_DEFAULT`, `USAC_ACCURATE`, `USAC_PROSAC` et +`USAC_MAGSAC`. La shortlist Gate A est FM_RANSAC comme baseline, puis USAC_DEFAULT, +USAC_MAGSAC et USAC_ACCURATE. PROSAC est `NOT_APPLICABLE` en v1 : la distance descriptor est +persistée mais l'ordre canonique suit l'index de query, pas un classement de qualité benchmarké. + +## Benchmark methodology + +Un corpus synthétique déterministe avec Fundamental ground truth couvrira bruit, outliers, +résolutions, tailles, géométries saines, faibles et adversariales. Les méthodes seront comparées +par précision/recall du masque, erreurs épipolaires, échecs, repeatability, temps et ressources. +Le benchmark lourd restera hors build et suite par défaut. Aucune fixture photo réelle ne sera +revendiquée sans fixture non sensible présente dans le dépôt. + +La campagne Gate A du 9 août 2026 utilise OpenCV 5.0.0, Clang 22.1.8, une seed fixe et 32 +répétitions. Elle couvre 7 à 8192 points, 0 à 100 % d'outliers, bruit 0 à 1,5 px, 1280×720 à +4000×3000, baseline faible/large, concentration, quasi-colinéarité, planéité, rotation dominante +et duplications. Aucune fixture photo réelle représentative n'existe dans le dépôt. + +| Algorithme | P/R 1024, 30 % | P/R 8192, 70 % | Médiane/p95/pire 8192 | Seed locale | Stable 32× | +|---|---:|---:|---:|---|---| +| FM_RANSAC | 0,998/0,720 | 0,993/0,413 | 316,4/318,9/319,7 ms | non | oui observé | +| USAC_DEFAULT | 0,996/0,960 | 0,997/0,959 | 43,0/43,2/44,8 ms | preset non | oui | +| USAC_MAGSAC | 0,993/0,965 | 0,994/0,962 | 11,4/12,0/12,1 ms | preset non | oui | +| USAC_ACCURATE | 0,996/0,960 | 0,996/0,961 | 30,5/32,0/32,1 ms | preset non | oui | +| MAGSAC params v1 | 0,997/0,957 | 0,996/0,894 | 10,8/11,0/11,4 ms | oui | oui | + +FM_RANSAC est rejeté pour son recall et son pire temps. DEFAULT et ACCURATE n'améliorent pas assez +la qualité pour leur coût. La production emploie des UsacParams explicites : la seed par appel +prime sur la variation du cas extrême liée à la seed fixe. À bruit 0,75 px/50 % d'outliers, les +seuils 0,5/1,0/1,5/2,0/3,0 donnent des recalls 0,535/0,811/0,961/0,990/1,000 et des precisions +0,996/0,988/0,990/0,986/0,985. Le compromis retenu est 1,5 px. + +## Determinism + +USAC expose `cv::UsacParams::randomGeneratorState`, un entier par appel, ainsi que les paramètres +de sampling, score, optimisation locale et polishing. Cette API est préférable à une mutation de +`cv::theRNG()` process-global. FM_RANSAC restera une baseline scientifique tant que son contrôle +RNG et sa repeatability n'ont pas été mesurés. + +Les cinq candidats ont donné un hash modèle+masque identique sur 32 appels et dans trois processus +distincts. La garantie v1 reste intra-environnement : mêmes octets, ordre, configuration, seed, +OpenCV 5.0.0 et architecture. Aucun bit-exact cross-version ou cross-architecture n'est promis. + +## Random seed policy + +La policy v1 calcule SHA-256 sur `L3DGVSE1`, le SHA-256 du Match File puis le fingerprint. Les +quatre premiers octets sont décodés little-endian et les 31 bits faibles alimentent +`randomGeneratorState`. La policy est version 1. + +## Parameter fingerprint + +Le fingerprint v1 est SHA-256 des 84 octets suivants. Les entiers sont little-endian ; les doubles +sont leurs bits IEEE-754 binary64 écrits comme `uint64_t` little-endian. NaN/Inf sont refusés et +le seul champ autorisant zéro signé, `min_inlier_ratio`, normalise `-0.0` en `+0.0`. Aucun octet ne +provient d'un dump de structure. + +| Offset | Taille | Champ | +|---:|---:|---| +| 0 | 8 | domaine ASCII `L3DGVFP1` | +| 8 | 4 | version encodage = 1 | +| 12 | 4 | kind FUNDAMENTAL = 1 | +| 16 | 4 | verifier version = 1 | +| 20 | 4 | algorithme USAC_MAGSAC explicite = 1 | +| 24 | 8 | threshold binary64 | +| 32 | 8 | confidence binary64 | +| 40 | 4 | max iterations | +| 44 | 4 | minimum inlier count | +| 48 | 8 | minimum inlier ratio binary64 | +| 56 | 4 | seed policy version | +| 60 | 4 | canonicalisation version | +| 64 | 1 | représentation Point2d = 2 | +| 65 | 1 | sampler uniforme = 0 | +| 66 | 1 | score MAGSAC = 2 | +| 67 | 1 | isParallel = 0 | +| 68 | 1 | LO inner = 1 | +| 69 | 4 | LO iterations = 5 | +| 73 | 4 | LO sample size = 14 | +| 77 | 1 | neighbor grid = 1 | +| 78 | 1 | COV polisher = 3 | +| 79 | 4 | polisher iterations = 3 | +| 83 | 1 | réservé nul | + +Le vector golden de la configuration production commence par les 84 octets hexadécimaux +`4c33444756465031...0300000000` et donne le SHA-256 +`ddb44bb070c62be66c405946e89cbb49c084f8f30a21d6f408dc239225b7bbd0`. Pour un Match File SHA +composé de 31 octets nuls puis `01`, cette configuration donne la seed décimale `1910542150`. +Les politiques de +ressources, hardware, PSI, lot, worker et réservation CPU ne sont ni des champs ni des entrées. + +## Acceptance policy + +Un modèle candidat qui échoue à cette policy publie REJECTED avec son masque et son compte +d'inliers, sans matrice. La production exige `inlier_count >= 16` et +`inlier_count / match_count >= 0,20`. Les cas 100 % faux produisent 10/64, 14/256, 26/1024 et +28/4096 inliers, ratio maximal 0,15625. Les scènes saines produisent 45/64, 129/256 et 297/1024 ; +la faible baseline produit 126/256. + +## Fundamental matrix canonicalization + +La production adopte cette canonicalisation version 1. Les neuf valeurs doivent être finies. La +norme de Frobenius est calculée avec une accumulation `hypot` résistante au débordement ; zéro est +refusé. Le premier coefficient de valeur absolue strictement maximale gagne, donc un tie conserve +le plus petit index ligne-major. Après division, le signe rend ce pivot positif et les zéros signés +sont normalisés à `+0.0`. Sur 8192/70 %, les singular values sont +3,392e-2, 1,374e-4 et 5,915e-24. OpenCV fournit déjà rank-2 à précision numérique. V1 ne calcule +aucune SVD en production, n'impose aucun seuil de rang et n'effectue aucune post-projection rank-2. +Les validations production portent uniquement sur la forme 3×3 unique, la finitude et la norme. + +## Inlier mask generation + +Le masque OpenCV est validé en type, taille et valeurs, puis converti sans réordonnancement vers +le bitset LSB-first du modèle. Les frontières 7/8/9, 63/64/65 et 8191/8192 sont testées. + +Le core conserve strictement l'ordre d'entrée du Match File. Les tests utilisent des indices B +permutés et des masques non contigus ; le bit `i` publié reste l'élément `i` du fichier, jamais +l'index de feature. Les tailles 1, 2, 7, 8, 9, 63, 64, 65, 8191 et 8192, le padding nul et le +popcount sont couverts avec le Model v1 inchangé. + +## Scientific rejection + +Un nombre de matches inférieur au minimum réel, l'absence de modèle sur entrée valide ou l'échec +de l'acceptance policy produit un résultat scientifique REJECTED cohérent. + +Le minimum USAC observé est sept. Moins de sept matches produit un masque zéro sans appel OpenCV ; +sept à quinze peuvent produire une hypothèse mais ne franchissent pas le support production. + +## Execution failure + +Match/Feature asset absent ou corrompu, index hors bornes, exception OpenCV, OOM, masque malformé, +matrice non finie ou invariant interne invalide échoue dans le Task Runtime. Aucun résultat +scientifique n'est publié dans ces cas. + +Le core traduit parent absent/NO_MATCH et Feature Set absent en erreur d'exécution `NOT_FOUND` ; +asset absent, tronqué, hash divergent, ownership ou index incohérent en `CORRUPT` ; exception ou +sortie estimator malformée/non finie en `ESTIMATOR_ERROR` ; `bad_alloc` en `OUT_OF_MEMORY` ; et +échec Model en `DATABASE_ERROR`. Les seams test-only couvrent erreur estimator, mask/matrice +malformés, NaN, OOM et publication. Aucun de ces chemins ne crée de résultat scientifique. + +## Resource bounds + +Une unité atomique est un Match Result, au maximum 8192 correspondances. Le Match File est borné +à 98 336 octets et le bitset à 1024 octets. Aucun cache global ni préchargement de projet complet +n'est utilisé. + +À 8192 matches, les allocations directement contrôlées maximales sont 98 304 octets d'entries, +393 216 octets de keypoints A/B, 262 144 octets de Point2d A/B, 1024 octets de bitset, environ +8192 octets de mask OpenCV et 72 octets de modèle, soit environ 745 Kio hors petits objets et +scratch OpenCV. Aucun descriptor ni matrice A×B n'est lu. Massif mesure 2,445 Mio de heap au pic +du test E2E complet, incluant SQLite, OpenCV, fixtures Feature Store et toutes les séquences de test. +Une réservation conservatrice de 4 Mio par job couvre ce profil mesuré. + +## CPU policy + +Le parallélisme OpenCV reste configuré process-wide. Le benchmark mesurera les threads réellement +consommés ; le verifier ne change pas `cv::setNumThreads()` par paire. + +La campagne a consommé environ 99 % d'un CPU logique : réservation v1 d'un thread et un worker. + +## GPU policy + +Aucun backend Vulkan n'est implémenté avant profil du chemin CPU final. La décision attendue est +`NOT_JUSTIFIED` si les unités restent sub-millisecondes ou de quelques millisecondes. + +Verdict Gate A : `NOT_JUSTIFIED`. Les cas usuels prennent 0,3 à 5,6 ms et le pire MAGSAC local +mesuré reste à 11,4 ms. Aucun backend Vulkan de vérification n'est implémenté. + +## Task Runtime + +L'audit Gate C conclut que le checkpoint générique v1 est insuffisant : il conserve l'état, +la progression, le compteur de séquences et les temps, mais aucun payload propre au kind. Le +reconstructeur doit retrouver les paramètres scientifiques immuables et le curseur sans les +inventer depuis un fingerprint irréversible. + +`geometric_verifier_tasks` est donc la seule raison de Project DB v13. Elle doit conserver +`task_id`, `after_match_result_id`, les sept paramètres de configuration v1 et le fingerprint +calculé à la création pour validation à la reconstruction. Aucun `cv::Mat`, buffer, état RNG, +paramètre Governor ou donnée hardware n'y appartient. La tâche calcule hors transaction et publie +chaque résultat par transaction courte avant avancement du curseur. + +Le Task Kind production est `geometric_verifier.run` version 1. Il pagine les Match Results par ID +strictement croissant avec une page de `batch + 1`, et traite des lots Governor 1/2/4/8. Les +parents autres que `MATCHED` avec `match_count > 0` sont seulement traversés par le curseur. Une +unité éligible appelle le core, qui reuse l'identité exacte avant toute lecture d'asset. + +WHY GENERIC TASK PERSISTENCE IS INSUFFICIENT: aucun champ de payload métier dans le snapshot v1. + +REQUIRED DURABLE FIELDS: configuration scientifique v1, fingerprint et dernier Match Result +publié puis checkpointé. + +WHY EXISTING DB CANNOT STORE THEM: `tasks` et `checkpoints` ne portent que le résumé générique ; +aucune table v12 ne possède une ligne 1:1 adaptée à ce Task Kind. + +## Checkpoint/recovery + +La pagination suit `match_result_id` croissant sans supposer des IDs contigus. Le résultat est +publié avant que `after_match_result_id` avance en mémoire ; le curseur n'est persisté qu'après le +lot. Après chaque lot non terminal, `task_sequence_break()` rend la réservation au Governor. + +Le test de crash publie puis interrompt avant checkpoint du curseur, ferme runtime et DB, recharge +le checkpoint antérieur et reconstruit le Task Kind. Le parent est revu, son résultat exact est +réutilisé, puis le curseur progresse. + +## Cancellation + +Pause et annulation sont coopératives avant chaque parent et entre lots. Une petite estimation +OpenCV engagée finit et publie avant l'arrêt ; aucun résultat scientifique CANCELLED n'est créé. +Les résultats déjà publiés restent durables. + +## Backend policy + +Un backend n'est transparent pour l'identité que si ses sorties scientifiques sont équivalentes +selon le contrat. V1 possède une seule implémentation CPU de production. + +## Core publication and reuse + +Le core charge les métadonnées DB, relâche les mutex internes après chaque API, lit les assets et +calcule sans transaction longue, puis appelle une publication Model v1 courte. Une identité exacte +VERIFIED ou REJECTED est retournée avant toute lecture Feature/Match et sans appel estimator. Une +contrainte concurrente déclenche un unique `find` de l'identité, jamais un overwrite ou une +récursion. Changer un paramètre scientifique produit un autre fingerprint et un autre résultat. + +Les tests E2E utilisent le vrai Project DB v13, deux Feature Files à 8192 points, des Match Files +hashés, le vrai MAGSAC et le Model v1. VERIFIED est rechargé après close/reopen avec modèle et +masque bit-identiques ; REJECTED conserve son support et est également réutilisé. + +## Production algorithm + +UsacParams explicites, sampler uniforme, score MAGSAC, non parallèle et seed locale par appel. +Les champs LO et polishing effectifs sont encodés explicitement ; aucun preset enum caché. + +## Production parameters + +FUNDAMENTAL version 1 ; seuil 1,5 px ; confiance 0,999 ; 5000 itérations ; 16 inliers ; ratio 0,20 ; +seed policy 1 ; canonicalisation 1 ; Point2d. Tous les champs scientifiques appartiennent au +fingerprint version 1. + +## Validation + +Gate A couvre corpus, comparaison, seed et repeatability. Gate B couvre fingerprint/seed golden, +canonicalisation, mapping bit à bit, frontières d'acceptation, E2E DB, reuse, corruption, +publication, 8192 matches et ASan/UBSan. Gate C couvre Task, publication avant curseur et reprise. + +Gate D a exécuté 1000 parents configurés dans la vraie Task, puis les reprises et variantes de +configuration du test : environ 2001 traversées réutilisées en 5,870 s, soit environ 341/s. Ce +run valide pagination, checkpoints, Governor et reuse ; il n'est pas une mesure de latence MAGSAC +et n'en revendique ni médiane ni p95. Le RSS pic observé est 25 964 Kio pour le processus de test +complet. `MemAvailable` passe de 10 702 988 à 10 692 916 Kio ; `pswpin/pswpout` restent 0/0 ; en +fin de run, PSI avg10 vaut 0,34 % CPU, 0 % mémoire et 0 % I/O. Le chemin calculé reste couvert par +le vrai E2E MAGSAC Gate B et ses bornes, sans campagne scientifique répétée. + +TSan couvre core, Task, sequencing et Governor (4/4), avec uniquement la suppression OpenCV +existante. Le build CPU-only couvre la suite normale (31/31). La suite normale ne contient ni +benchmark lourd ni stress. Le clean build Clang/Clang++ et la campagne normale finale passent +32/32 avec ORB Vulkan matériel sur Radeon 780M RADV PHOENIX. + +## Out of scope + +Tracks, model competition, classification planaire ou faible parallaxe, Essential, calibration, +pose, triangulation, bundle adjustment, SfM et Vulkan RANSAC. diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 61ba7da..f6dcdd7 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -1,7 +1,8 @@ # Base de données projet Lardon3D -> Version courante : **v12**. La migration transactionnelle v11→v12 ajoute le -> modèle immutable `geometric_verification_results`. La migration v10→v11 ajoute +> Version courante : **v13**. La migration transactionnelle v12→v13 ajoute +> uniquement `geometric_verifier_tasks` pour la tâche durable. La migration +> v11→v12 ajoute le modèle immutable `geometric_verification_results`. La migration v10→v11 ajoute > `matcher_tasks` pour la tâche Matcher durable. La version v10 publiée ajoute > uniquement `match_results` pour le Match Result Model. La migration v8→v9 > ajoute la table `candidate_pair_generate_tasks` pour la tâche durable @@ -12,7 +13,8 @@ ## Vision -La base de données projet stocke les métadonnées de reconstruction et les relations entre les entités. Elle est conçue pour être légère, persistante et permettre la reprise après interruption. +La base de données projet stocke les métadonnées de reconstruction et les relations entre les +entités. Elle est conçue pour être légère, persistante et permettre la reprise après interruption. ## Structure conceptuelle @@ -338,6 +340,14 @@ Le curseur est le dernier `candidate_pair_id` checkpointé. Il n'implique ni continuité des IDs ni liste persistée de Candidate Pairs. La configuration ne peut pas changer lors d'un UPSERT ; seul le curseur avance. +## Schéma v13 implémenté + +La migration v12→v13 ajoute uniquement `geometric_verifier_tasks`. Elle porte +le curseur `after_match_result_id`, les sept paramètres scientifiques v1 et le +fingerprint de contrôle. L'UPSERT autorise seulement le curseur à évoluer ; la +configuration reste immuable. La migration est transactionnelle, son rollback +forcé conserve une vraie v12 sans la table et un retry termine en v13. + Le Match Result ci-dessous reste le contrat publié de v10 : ```sql @@ -379,10 +389,13 @@ CREATE INDEX match_results_feature_set_b_idx ``` **Invariants** : -- `UNIQUE(candidate_pair_id, feature_set_id_a, feature_set_id_b, matcher_kind, matcher_version, parameter_fingerprint)` : identité déterministe 6 parties -- `candidate_pair_id` référence `candidate_pairs(candidate_pair_id)` avec l'action par défaut (NO ACTION) +- `UNIQUE(candidate_pair_id, feature_set_id_a, feature_set_id_b, matcher_kind, matcher_version, + parameter_fingerprint)` : identité déterministe 6 parties +- `candidate_pair_id` référence `candidate_pairs(candidate_pair_id)` avec l'action par défaut + (NO ACTION) - `feature_set_id_a` et `feature_set_id_b` référencent `feature_sets(feature_set_id)` -- `feature_set_id_a` appartient à `image_id_a` de la Candidate Pair, `feature_set_id_b` appartient à `image_id_b` (validé par l'API create) +- `feature_set_id_a` appartient à `image_id_a` de la Candidate Pair, `feature_set_id_b` appartient + à `image_id_b` (validé par l'API create) - `NO_MATCH` impose `match_count=0` et aucun asset - `MATCHED` impose `match_count>0` et SHA/path/taille complets - les échecs d'exécution restent dans le Task Runtime et ne créent pas de ligne @@ -392,7 +405,8 @@ CREATE INDEX match_results_feature_set_b_idx **API** : - `lardon3d_project_db_create_match_result()` — INSERT avec validation des contraintes - `lardon3d_project_db_load_match_result()` — SELECT par ID -- `lardon3d_project_db_find_match_result()` — SELECT par (candidate_pair_id, feature_set_id_a, feature_set_id_b, matcher_kind, matcher_version, parameter_fingerprint) +- `lardon3d_project_db_find_match_result()` — SELECT par (candidate_pair_id, feature_set_id_a, + feature_set_id_b, matcher_kind, matcher_version, parameter_fingerprint) - `lardon3d_project_db_list_match_results()` — SELECT paginé ORDER BY id - `lardon3d_project_db_record_matcher_task()` — UPSERT configuration/curseur - `lardon3d_project_db_load_matcher_task()` — SELECT par task_id @@ -444,18 +458,20 @@ Le contrat complet, dont l'ordre des bits, est dans ## Ouverture et migrations -Une DB vide reçoit la chaîne de schémas jusqu'à v12 dans une transaction +Une DB vide reçoit la chaîne de schémas jusqu'à v13 dans une transaction `BEGIN IMMEDIATE`. Une DB v1 reçoit transactionnellement les colonnes nullable `task_kind` et `task_kind_version`, puis les migrations v2→v3. Les anciennes lignes restent `NULL/NULL`, sans type inventé et sans perte des projets, tâches, checkpoints ou -artefacts. Une interruption ou erreur provoque un rollback complet. Les DB v1, -v2, v3, v4, v5, v6, v7, v8, v9, v10 et v11 sont migrées séquentiellement vers v12. +artefacts. Une interruption ou erreur provoque un rollback complet. Les DB v1 +à v12 sont migrées séquentiellement vers v13. Une v10 publiée est validée comme telle avant que v10→v11 crée `matcher_tasks` ; son absence n'est donc pas une corruption. Une version future est refusée et une DB contenant des tables sans métadonnée de version est considérée corrompue. La fonction interne de migration applique uniquement la chaîne séquentielle connue jusqu'à -v12 ; une valeur hors de 1..12 est refusée. La failure injectée v12 rollbacke +v13 ; une valeur hors de 1..13 est refusée. La failure injectée v12 rollbacke la table, l'index et le changement de version, laissant une vraie v11 utilisable. +La failure injectée v13 conserve une vraie v12 sans `geometric_verifier_tasks` ; +un retry applique ensuite v12→v13. Migration v1→v2 exacte, exécutée entre `BEGIN IMMEDIATE` et `COMMIT` : @@ -592,8 +608,8 @@ ouvert. ## Statut -**IMPLEMENTED** — SQLite système, schéma v12 et migrations séquentielles -v1→v2→v3→v4→v5→v6→v7→v8→v9→v10→v11→v12, identité projet, transactions +**IMPLEMENTED** — SQLite système, schéma v13 et migrations séquentielles +v1→v2→v3→v4→v5→v6→v7→v8→v9→v10→v11→v12→v13, identité projet, transactions tâche+checkpoint, pagination de reprise et artefacts génériques. **IMPLEMENTED** — ouverture/fermeture avec le projet, identité INI/DB cohérente, diff --git a/docs/architecture/reconstruction_pipeline.md b/docs/architecture/reconstruction_pipeline.md index 60b216d..08e4985 100644 --- a/docs/architecture/reconstruction_pipeline.md +++ b/docs/architecture/reconstruction_pipeline.md @@ -97,19 +97,20 @@ Index, persistance, canonicalisation, idempotence et tâche durable. | **Orchestration** | `matcher.run` traite les Candidate Pairs par pages et lots durables de 1/2/4/8. | **Statut :** IMPLEMENTED v1 — Matcher, Match Store, reprise idempotente et Task -durable. Le calcul Geometric Verifier reste l'étape suivante, non commencée. +durable. Le Geometric Verifier consomme désormais ces résultats. Le Match Result appartient à Project DB v10 et la tâche durable `matcher.run` à Project DB v11. ### F2. Geometric Verification -Project DB v12 implémente uniquement le modèle scientifique persistant. Chaque +Project DB v12 implémente le modèle scientifique persistant. Chaque résultat appartient à un Match Result `MATCHED`, possède une identité exacte, un masque compact borné et, si VERIFIED, une matrice fondamentale 3×3 finie. -La publication est atomique et immutable. Le calcul, son task kind et le choix -d'algorithme restent planifiés dans Geometric Verifier v1. +La publication est atomique et immutable. Project DB v13 ajoute uniquement la +persistance de `geometric_verifier.run` v1. Le calcul Fundamental emploie +USAC/MAGSAC avec configuration, seed et fingerprint déterministes. -**Statut :** MODEL IMPLEMENTED v1 — VERIFIER PLANNED. +**Statut :** IMPLEMENTED v1 — MODEL, VERIFIER ET TASK DURABLE. --- diff --git a/docs/architecture/resource_aware_pipeline.md b/docs/architecture/resource_aware_pipeline.md index 29fab6b..deb1713 100644 --- a/docs/architecture/resource_aware_pipeline.md +++ b/docs/architecture/resource_aware_pipeline.md @@ -55,13 +55,14 @@ chaque paire et entre les lots. Un crash après publication mais avant le checkpoint revoit la paire : le Matcher réutilise alors le Match Result et ne recalcule pas les descripteurs. -## Geometric Verification Model +## Geometric Verification Project DB v12 stocke un résultat borné à 1024 octets de masque et neuf -binary64. Le modèle ne lit aucun signal matériel et ne modifie pas le Governor. -Le futur verifier calculera une paire hors transaction, publiera par une courte -transaction, checkpoint puis libérera ses buffers avant réadmission. Admission, -threads et lots resteront exclusivement décidés par Runtime et Governor. +binary64. `geometric_verifier.run` v1 traite un Match Result atomique, publie +par une transaction courte puis checkpoint son curseur par lots 1/2/4/8 avant +`task_sequence_break()`. Sa ligne durable appartient à Project DB v13. Le job +réserve un CPU logique et 4 Mio, sans slot GPU. Admission, pression, lots et +slow-start restent exclusivement décidés par Runtime et Governor. ## GPU et files @@ -84,6 +85,7 @@ reste inchangée. - hard floor `MemAvailable` : un huitième, environ 1,9 Gio ; - Feature workers : 1 ; batch : 1 image ; - Matcher workers : 1 ; lots adaptatifs 1, 2, 4 ou 8 Candidate Pairs ; +- Geometric Verifier workers : 1 ; lots adaptatifs 1, 2, 4 ou 8 Match Results ; - profondeur de la Task Queue : 64 tâches légères, un seul callback actif ; - PSI CPU avg10 : nouvelle admission suspendue à 20 % ; - PSI mémoire avg10 : nouvelle admission suspendue à 1 % ; @@ -93,6 +95,13 @@ Le benchmark Matcher 8192 mesure environ 70 ms ORB et 135 ms SIFT à 12 threads, contre 68 ms et 127 ms à 16 threads : le profil interactif abandonne environ 3–7 % de latence isolée pour réserver quatre threads logiques au desktop. +Le run soutenu Geometric Verifier traverse environ 2001 parents réutilisés en +5,870 s via Task, DB, checkpoints et Governor. Le processus de test culmine à +25 964 Kio RSS ; `MemAvailable` reste au-dessus de 10,69 Gio et les compteurs +swap restent nuls. PSI avg10 final vaut 0,34 % CPU et 0 % mémoire/I/O. Cette +mesure valide le chemin resource-aware et la reprise ; elle ne prétend pas être +une distribution de latence estimator-only. + ## Limites Le profil maximal explicite et les pools multi-workers restent hors périmètre. diff --git a/docs/architecture/task_kind_registry.md b/docs/architecture/task_kind_registry.md index 36f78ac..213a2c6 100644 --- a/docs/architecture/task_kind_registry.md +++ b/docs/architecture/task_kind_registry.md @@ -69,3 +69,8 @@ l'identité Feature Set et le curseur `after_candidate_pair_id`. Il traite une Candidate Pair atomique à la fois dans des lots bornés à huit, checkpoint le curseur et repasse par le Governor entre les lots. La table durable `matcher_tasks` est introduite par Project DB v11, après le Match Result v10. + +**IMPLEMENTED** — `geometric_verifier.run`, version 1, recharge la configuration +Fundamental immuable, en revalide le fingerprint et reprend `after_match_result_id`. +Project DB v13 ajoute uniquement `geometric_verifier_tasks`, car le checkpoint +générique v1 ne possède aucun payload propre au kind. diff --git a/docs/architecture/task_system.md b/docs/architecture/task_system.md index ea52959..5017b84 100644 --- a/docs/architecture/task_system.md +++ b/docs/architecture/task_system.md @@ -111,10 +111,11 @@ par lots adaptatifs de 1, 2, 4 ou 8. Il persiste le curseur effectue une rupture de séquence avant le suivant. Une paire repassée après un crash est réutilisée par son Match Result. -Geometric Verification Model v1 n'ajoute aucun task kind ni état runtime. -Project DB v12 ne contient que les deux états scientifiques terminaux. Le futur -Geometric Verifier v1 devra obtenir réservation, pause/cancel, lot et checkpoint -dans un ticket séparé avant toute exécution. +**IMPLEMENTED** — `geometric_verifier.run` v1 traite un Match Result atomique à +la fois, par lots adaptatifs de 1, 2, 4 ou 8. Project DB v13 conserve sa +configuration scientifique et `after_match_result_id`. Chaque résultat est +publié avant le curseur ; pause, annulation, checkpoint et rupture de séquence +restent coopératifs aux frontières des parents et des lots. 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 diff --git a/docs/performance/target_hardware.md b/docs/performance/target_hardware.md index 7e81486..abafd15 100644 --- a/docs/performance/target_hardware.md +++ b/docs/performance/target_hardware.md @@ -70,6 +70,30 @@ de parité couvrent exactement le top-2 jusqu'à 8192, le Match File complet et fallback CPU. Ces nombres décrivent la machine mesurée et ne sont pas un contrat portable de latence. +## Geometric Verifier Fundamental — Gate A + +Le 9 août 2026, `benchmark-geometric-verifier` a comparé FM_RANSAC, USAC_DEFAULT, USAC_MAGSAC, +USAC_ACCURATE et MAGSAC à seed locale sur le Ryzen 7 8845HS, OpenCV 5.0.0 et Clang 22.1.8. Sur +8192 correspondances, 70 % d'outliers et bruit 0,75 px, le candidat local mesure +10,80/11,04/11,35 ms médiane/p95/pire. FM_RANSAC mesure 316,44/318,87/319,66 ms avec seulement +0,413 de recall. + +Avant campagne, PSI CPU/mémoire/I/O avg10 était nul, `MemAvailable` environ 9,72 Gio et +`pswpin/pswpout` nul. Après 30,4 s à environ 99 % d'un CPU, PSI et swap restaient nuls, +`MemAvailable` environ 9,74 Gio et le capteur CPU observé passait d'environ 54,6 à 55 °C. Ce sont +des proxies objectifs, pas une mesure subjective de fluidité. + +Le coût court et le faible working set rendent Vulkan `NOT_JUSTIFIED` pour ce verifier sur la +Radeon 780M. Le GPU reste utilisé uniquement par le Matcher ORB existant. + +Le profil production réserve un CPU logique, 4 Mio et un worker, avec lots 1/2/4/8. Un run de la +vraie Task sur 1000 parents, suivi de ses chemins de reprise/configuration, a traversé environ +2001 parents réutilisés en 5,870 s (environ 341/s). Ce chiffre inclut DB, checkpoints et fixture ; +il ne remplace pas la latence estimator-only Gate A. Le processus de test a culminé à 25 964 Kio +RSS. `MemAvailable` a varié de 10 702 988 à 10 692 916 Kio, sans swap ; PSI avg10 final était +0,34 % CPU et 0 % mémoire/I/O. Les latences médiane/p95 par parent et le RSS début/fin n'étaient +pas mesurables avec ce harness et ne sont donc pas extrapolés. + ## Feasibility Vulkan SIFT / RootSIFT Sur le même RADV PHOENIX, `shaderFloat64`, les timestamps compute, un subgroup diff --git a/include/lardon3d/geometric_verifier.h b/include/lardon3d/geometric_verifier.h new file mode 100644 index 0000000..d10e8c3 --- /dev/null +++ b/include/lardon3d/geometric_verifier.h @@ -0,0 +1,65 @@ +#ifndef LARDON3D_GEOMETRIC_VERIFIER_H +#define LARDON3D_GEOMETRIC_VERIFIER_H + +#include +#include + +#include + +enum { + LARDON3D_GEOMETRIC_VERIFIER_VERSION = 1, + LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE = 84, + LARDON3D_GEOMETRIC_VERIFIER_MINIMUM_MATCHES = 7, +}; + +typedef enum { + LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC = 1, + LARDON3D_GEOMETRIC_ALGORITHM_USAC_DEFAULT = 2, +} Lardon3DGeometricVerifierAlgorithm; + +typedef struct { + double threshold_pixels; + double confidence; + uint32_t max_iterations; + uint32_t min_inlier_count; + double min_inlier_ratio; + uint32_t seed_policy_version; + uint32_t canonicalization_version; +} Lardon3DGeometricVerifierParameters; + +typedef enum { + LARDON3D_GEOMETRIC_VERIFIER_OK = 0, + LARDON3D_GEOMETRIC_VERIFIER_INVALID_ARGUMENT, + LARDON3D_GEOMETRIC_VERIFIER_NOT_FOUND, + LARDON3D_GEOMETRIC_VERIFIER_CORRUPT, + LARDON3D_GEOMETRIC_VERIFIER_OUT_OF_MEMORY, + LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR, + LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR, +} Lardon3DGeometricVerifierResult; + +Lardon3DGeometricVerifierParameters +lardon3d_geometric_verifier_default_parameters(void); +bool lardon3d_geometric_verifier_parameters_valid( + const Lardon3DGeometricVerifierParameters *parameters); +bool lardon3d_geometric_verifier_fingerprint_bytes( + const Lardon3DGeometricVerifierParameters *parameters, + Lardon3DGeometricVerifierAlgorithm algorithm, uint32_t verifier_version, + unsigned char bytes[LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE]); +void lardon3d_geometric_verifier_fingerprint( + const Lardon3DGeometricVerifierParameters *parameters, + unsigned char fingerprint[32]); +bool lardon3d_geometric_verifier_canonicalize(double model[9]); +uint32_t lardon3d_geometric_verifier_seed(const unsigned char match_sha256[32], + const unsigned char fingerprint[32]); +Lardon3DGeometricVerifierResult lardon3d_geometric_verifier_verify_and_publish( + const char *project_path, Lardon3DProjectDb *database, + uint64_t match_result_id, + const Lardon3DGeometricVerifierParameters *parameters, + Lardon3DProjectDbGeometricVerificationResult *result, bool *reused); + +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING +void lardon3d_geometric_verifier_test_reset_estimator_calls(void); +uint32_t lardon3d_geometric_verifier_test_estimator_calls(void); +#endif + +#endif diff --git a/include/lardon3d/geometric_verifier_task.h b/include/lardon3d/geometric_verifier_task.h new file mode 100644 index 0000000..da7c0ac --- /dev/null +++ b/include/lardon3d/geometric_verifier_task.h @@ -0,0 +1,35 @@ +#ifndef LARDON3D_GEOMETRIC_VERIFIER_TASK_H +#define LARDON3D_GEOMETRIC_VERIFIER_TASK_H + +#include +#include + +#include +#include +#include + +#define LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND "geometric_verifier.run" + +enum { + LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND_VERSION = 1, + LARDON3D_GEOMETRIC_VERIFIER_TASK_MINIMUM_BATCH = 1, + LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH = 8, +}; + +typedef struct { + Lardon3DGeometricVerifierParameters verifier; +} Lardon3DGeometricVerifierTaskConfiguration; + +Lardon3DTask *lardon3d_project_create_geometric_verifier_task( + Lardon3DAppState *state, + const Lardon3DGeometricVerifierTaskConfiguration *configuration, + uint64_t *task_id); +bool lardon3d_project_enqueue_geometric_verifier_task( + Lardon3DAppState *state, + const Lardon3DGeometricVerifierTaskConfiguration *configuration, + uint64_t *task_id); +bool lardon3d_geometric_verifier_task_reconstruct( + const Lardon3DTaskDurableSnapshot *snapshot, void *context, + Lardon3DTaskKindBinding *binding); + +#endif diff --git a/include/lardon3d/project.h b/include/lardon3d/project.h index d92f023..45fd892 100644 --- a/include/lardon3d/project.h +++ b/include/lardon3d/project.h @@ -73,6 +73,9 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_candidate_pair_g Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_matcher_task( Lardon3DAppState *state, const Lardon3DTask *task, const Lardon3DProjectDbMatcherTask *parameters); +Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_geometric_verifier_task( + Lardon3DAppState *state, const Lardon3DTask *task, + const Lardon3DProjectDbGeometricVerifierTask *parameters); Lardon3DProjectDbResult lardon3d_project_list_recoverable(Lardon3DAppState *state, const Lardon3DTaskKindRegistry *registry, uint64_t after_task_id, diff --git a/include/lardon3d/project_db.h b/include/lardon3d/project_db.h index da06946..a41fbd8 100644 --- a/include/lardon3d/project_db.h +++ b/include/lardon3d/project_db.h @@ -9,7 +9,7 @@ #include enum { - LARDON3D_PROJECT_DB_SCHEMA_VERSION = 12, + LARDON3D_PROJECT_DB_SCHEMA_VERSION = 13, LARDON3D_PROJECT_DB_ID_CAPACITY = 65, LARDON3D_PROJECT_DB_KIND_CAPACITY = 65, LARDON3D_PROJECT_DB_PATH_CAPACITY = 4096, @@ -144,6 +144,19 @@ typedef struct { float ratio_threshold; } Lardon3DProjectDbMatcherTask; +typedef struct { + uint64_t task_id; + uint64_t after_match_result_id; + double threshold_pixels; + double confidence; + uint32_t max_iterations; + uint32_t min_inlier_count; + double min_inlier_ratio; + uint32_t seed_policy_version; + uint32_t canonicalization_version; + unsigned char parameter_fingerprint[LARDON3D_PROJECT_DB_SHA256_SIZE]; +} Lardon3DProjectDbGeometricVerifierTask; + typedef struct { uint64_t match_result_id; uint64_t candidate_pair_id; @@ -508,6 +521,14 @@ Lardon3DProjectDbResult lardon3d_project_db_record_matcher_task( Lardon3DProjectDbResult lardon3d_project_db_load_matcher_task( Lardon3DProjectDb *database, uint64_t task_id, Lardon3DProjectDbMatcherTask *parameters); +Lardon3DProjectDbResult lardon3d_project_db_record_geometric_verifier_task( + Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, + const char *task_kind, uint32_t task_kind_version, + const Lardon3DProjectDbCheckpoint *checkpoint, + const Lardon3DProjectDbGeometricVerifierTask *parameters, int64_t updated_at); +Lardon3DProjectDbResult lardon3d_project_db_load_geometric_verifier_task( + Lardon3DProjectDb *database, uint64_t task_id, + Lardon3DProjectDbGeometricVerifierTask *parameters); Lardon3DProjectDbResult lardon3d_project_db_create_match_result( Lardon3DProjectDb *database, uint64_t candidate_pair_id, uint64_t feature_set_id_a, diff --git a/meson.build b/meson.build index ea62743..594a671 100644 --- a/meson.build +++ b/meson.build @@ -28,7 +28,7 @@ opencv = dependency( 'opencv5', required: true, include_type: 'system', - modules: ['opencv_core', 'opencv_imgcodecs', 'opencv_features2d'], + modules: ['opencv_core', 'opencv_imgcodecs', 'opencv_features2d', 'opencv_geometry'], ) opencv_benchmark = dependency( 'opencv5', @@ -37,6 +37,12 @@ opencv_benchmark = dependency( modules: ['opencv_core', 'opencv_imgcodecs', 'opencv_features2d', 'opencv_imgproc'], ) +opencv_geometry = dependency( + 'opencv5', + version: '>=5.0.0', + modules: ['opencv_core', 'opencv_geometry'], +) + vulkan_orb = get_option('vulkan_orb') vulkan = dependency('vulkan', required: vulkan_orb) glslc = find_program('glslc', required: vulkan_orb) @@ -134,6 +140,7 @@ executable( 'src/candidate_pair_gen.c', 'src/candidate_pair_task.c', 'src/matcher_task.c', + 'src/geometric_verifier_task.c', 'src/image_view.c', 'src/project.c', 'src/project_db.c', @@ -147,9 +154,10 @@ executable( 'src/hardware_profile.c', 'src/match_file.c', 'src/matcher.cpp', + 'src/geometric_verifier.cpp', ] + matcher_backend_sources, include_directories: include_directories('include'), - dependencies: [ncursesw, threads, sqlite3, openssl, opencv] + dependencies: [ncursesw, threads, sqlite3, openssl, opencv, opencv_geometry] + matcher_backend_dependencies, ) @@ -185,6 +193,7 @@ import_task_test = executable( 'src/task_kind_registry.c', 'src/task_kinds.c', 'src/matcher_task.c', 'src/matcher.cpp', 'src/match_file.c', + 'src/geometric_verifier_task.c', 'src/geometric_verifier.cpp', 'src/feature_task.c', 'src/sift_task.c', 'src/precision_features.c', 'src/feature_store.c', 'src/visual_index.c', @@ -324,6 +333,7 @@ candidate_pair_task_test = executable( 'src/project.c', 'src/project_db.c', 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', 'src/task_kinds.c', 'src/matcher_task.c', 'src/matcher.cpp', + 'src/geometric_verifier_task.c', 'src/geometric_verifier.cpp', 'src/match_file.c', 'src/task_queue.c', 'src/import.c', 'src/import_task.c', 'src/image_catalog.c', 'src/image_catalog_persistent.c', 'src/image_view.c', @@ -353,6 +363,7 @@ matcher_task_test = executable( 'src/project_db.c', 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', 'src/task_kinds.c', 'src/task_queue.c', 'src/matcher_task.c', 'src/matcher.cpp', 'src/match_file.c', + 'src/geometric_verifier_task.c', 'src/geometric_verifier.cpp', 'src/feature_store.c', 'src/resource_governor.c', 'src/resource_snapshot.c', 'src/hardware_profile.c', 'src/import.c', 'src/import_task.c', 'src/image_catalog.c', @@ -378,6 +389,7 @@ feature_task_test = executable( 'src/project_db.c', 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', 'src/task_kinds.c', 'src/task_queue.c', 'src/matcher_task.c', 'src/matcher.cpp', 'src/match_file.c', + 'src/geometric_verifier_task.c', 'src/geometric_verifier.cpp', 'src/import.c', 'src/import_task.c', 'src/image_catalog.c', 'src/image_catalog_persistent.c', 'src/image_view.c', 'src/feature_task.c', 'src/sift_task.c', 'src/precision_features.c', @@ -411,6 +423,7 @@ precision_consolidation_test = executable( 'src/project.c', 'src/project_db.c', 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', 'src/task_kinds.c', 'src/matcher_task.c', 'src/matcher.cpp', + 'src/geometric_verifier_task.c', 'src/geometric_verifier.cpp', 'src/match_file.c', 'src/task_queue.c', 'src/import.c', 'src/import_task.c', 'src/image_catalog.c', 'src/image_catalog_persistent.c', 'src/image_view.c', @@ -535,6 +548,7 @@ project_test = executable( 'src/task_kind_registry.c', 'src/task_kinds.c', 'src/matcher_task.c', 'src/matcher.cpp', 'src/match_file.c', + 'src/geometric_verifier_task.c', 'src/geometric_verifier.cpp', 'src/feature_task.c', 'src/sift_task.c', 'src/precision_features.c', 'src/feature_store.c', 'src/visual_index.c', @@ -763,3 +777,57 @@ executable( include_directories: include_directories('include'), dependencies: [threads, sqlite3, openssl, opencv] + matcher_backend_dependencies, ) + +executable( + 'benchmark-geometric-verifier', + sources: ['tests/benchmark_geometric_verifier.cpp'], + build_by_default: false, + dependencies: [opencv_geometry], +) + +geometric_verifier_test = executable( + 'test-geometric-verifier-core', + sources: [ + 'tests/test_geometric_verifier_core.cpp', + 'src/geometric_verifier.cpp', + 'src/match_file.c', + 'src/feature_store.c', + 'src/feature_extractor_opencv.cpp', + 'src/project_db.c', + 'src/task.c', + 'src/resource_governor.c', + 'src/resource_snapshot.c', + 'src/image_catalog_persistent.c', + 'src/app_state.c', + ], + cpp_args: ['-DLARDON3D_GEOMETRIC_VERIFIER_TESTING'], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv_geometry], +) + +test('geometric-verifier-core', geometric_verifier_test, timeout: 60) + +geometric_verifier_task_test = executable( + 'test-geometric-verifier-task', + sources: [ + 'tests/test_geometric_verifier_task.c', 'src/app_state.c', 'src/project.c', + 'src/project_db.c', 'src/task.c', 'src/task_checkpoint.c', + 'src/task_kind_registry.c', 'src/task_kinds.c', 'src/task_queue.c', + 'src/geometric_verifier_task.c', 'src/geometric_verifier.cpp', + 'src/match_file.c', 'src/feature_store.c', 'src/resource_governor.c', + 'src/resource_snapshot.c', 'src/import.c', 'src/import_task.c', + 'src/image_catalog.c', 'src/image_catalog_persistent.c', 'src/image_view.c', + 'src/feature_task.c', 'src/sift_task.c', 'src/precision_features.c', + 'src/feature_extractor_opencv.cpp', 'src/visual_index.c', + 'src/visual_index_task.c', 'src/candidate_pair_gen.c', + 'src/candidate_pair_task.c', 'src/matcher_task.c', 'src/matcher.cpp', + ] + matcher_backend_sources, + c_args: [ + '-DLARDON3D_PROJECT_DB_TESTING', + '-DLARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv] + matcher_backend_dependencies, +) +test('geometric-verifier-task', geometric_verifier_task_test, timeout: 60, + env: opencv_test_environment) diff --git a/src/geometric_verifier.cpp b/src/geometric_verifier.cpp new file mode 100644 index 0000000..3770d7c --- /dev/null +++ b/src/geometric_verifier.cpp @@ -0,0 +1,411 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +} + +namespace { + +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING +uint32_t estimator_calls; +#endif + +void put_u32(unsigned char *bytes, uint32_t value) { + for (unsigned int index = 0; index < 4; ++index) + bytes[index] = static_cast(value >> (8U * index)); +} + +void put_u64(unsigned char *bytes, uint64_t value) { + for (unsigned int index = 0; index < 8; ++index) + bytes[index] = static_cast(value >> (8U * index)); +} + +uint64_t double_bits(double value) { + uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; +} + +bool digest(const unsigned char *bytes, size_t size, unsigned char output[32]) { + unsigned int output_size = 0; + return EVP_Digest(bytes, size, output, &output_size, EVP_sha256(), nullptr) == + 1 && + output_size == 32; +} + +double normalized_zero(double value) { return value == 0.0 ? 0.0 : value; } + +bool join_path(char output[4096], const char *root, const char *relative) { + if (!root || !relative || relative[0] == '/' || std::strstr(relative, "..")) + return false; + int length = std::snprintf(output, 4096, "%s/%s", root, relative); + return length > 0 && length < 4096; +} + +bool read_points(const char *project_path, + const Lardon3DProjectDbFeatureSet &set, + std::vector &points) { + Lardon3DFeatureReader *reader = nullptr; + Lardon3DFeatureFileMetadata metadata; + if (lardon3d_feature_reader_open(project_path, &set, &reader, &metadata) != + LARDON3D_FEATURE_STORE_OK) + return false; + points.resize(set.feature_count); + bool ok = metadata.feature_count == set.feature_count; + for (uint32_t start = 0; ok && start < set.feature_count; start += 256) { + size_t count = std::min(256, set.feature_count - start); + ok = lardon3d_feature_reader_keypoints(reader, start, points.data() + start, + count) == LARDON3D_FEATURE_STORE_OK; + } + lardon3d_feature_reader_close(reader); + return ok; +} + +bool estimate_fundamental(const std::vector &points_a, + const std::vector &points_b, + const Lardon3DGeometricVerifierParameters *parameters, + uint32_t seed, cv::Mat &model, cv::Mat &mask) { +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING + ++estimator_calls; + const char *behavior = std::getenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR"); + if (behavior && std::strcmp(behavior, "error") == 0) + return false; + if (behavior && std::strcmp(behavior, "bad-alloc") == 0) + throw std::bad_alloc(); + if (behavior && std::strcmp(behavior, "empty") == 0) + return true; + if (behavior && std::strcmp(behavior, "malformed-mask") == 0) { + model = cv::Mat::eye(3, 3, CV_64F); + mask = cv::Mat::ones(1, 1, CV_8U); + return true; + } + if (behavior && std::strcmp(behavior, "malformed-model") == 0) { + model = cv::Mat::eye(2, 2, CV_64F); + mask = cv::Mat::ones(static_cast(points_a.size()), 1, CV_8U); + return true; + } + if (behavior && std::strcmp(behavior, "nan-model") == 0) { + model = cv::Mat::eye(3, 3, CV_64F); + model.at(0, 0) = NAN; + mask = cv::Mat::ones(static_cast(points_a.size()), 1, CV_8U); + return true; + } + if (behavior && std::strcmp(behavior, "controlled") == 0) { + static const double fundamental[9] = {0.0, 0.0, 0.0, 0.0, 0.0, + -1.0, 0.0, 1.0, 0.0}; + model = cv::Mat(3, 3, CV_64F, const_cast(fundamental)).clone(); + mask = cv::Mat::zeros(static_cast(points_a.size()), 1, CV_8U); + const char *bits = std::getenv("LARDON3D_TEST_GEOMETRIC_MASK"); + if (!bits || std::strlen(bits) != points_a.size()) + return false; + for (size_t index = 0; index < points_a.size(); ++index) { + if (bits[index] != '0' && bits[index] != '1') + return false; + mask.ptr()[index] = + static_cast(bits[index] - '0'); + } + return true; + } +#endif + cv::UsacParams params; + params.confidence = parameters->confidence; + params.isParallel = false; + params.loIterations = 5; + params.loMethod = cv::LOCAL_OPTIM_INNER_LO; + params.loSampleSize = 14; + params.maxIterations = static_cast(parameters->max_iterations); + params.neighborsSearch = cv::NEIGH_GRID; + params.randomGeneratorState = static_cast(seed); + params.sampler = cv::SAMPLING_UNIFORM; + params.score = cv::SCORE_METHOD_MAGSAC; + params.threshold = parameters->threshold_pixels; + params.final_polisher = cv::COV_POLISHER; + params.final_polisher_iterations = 3; + model = cv::findFundamentalMat(points_a, points_b, mask, params); + return true; +} + +} // namespace + +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING +extern "C" void lardon3d_geometric_verifier_test_reset_estimator_calls(void) { + estimator_calls = 0; +} + +extern "C" uint32_t lardon3d_geometric_verifier_test_estimator_calls(void) { + return estimator_calls; +} +#endif + +extern "C" Lardon3DGeometricVerifierParameters +lardon3d_geometric_verifier_default_parameters(void) { + return {1.5, 0.999, 5000, 16, 0.20, 1, 1}; +} + +extern "C" bool lardon3d_geometric_verifier_parameters_valid( + const Lardon3DGeometricVerifierParameters *p) { + return p && std::isfinite(p->threshold_pixels) && p->threshold_pixels > 0.0 && + std::isfinite(p->confidence) && p->confidence > 0.0 && + p->confidence < 1.0 && p->max_iterations > 0 && + p->max_iterations <= static_cast(INT_MAX) && + p->min_inlier_count > 0 && + p->min_inlier_count <= LARDON3D_MATCH_FILE_MAX_MATCHES && + std::isfinite(p->min_inlier_ratio) && p->min_inlier_ratio >= 0.0 && + p->min_inlier_ratio <= 1.0 && p->seed_policy_version == 1 && + p->canonicalization_version == 1; +} + +extern "C" bool lardon3d_geometric_verifier_fingerprint_bytes( + const Lardon3DGeometricVerifierParameters *p, + Lardon3DGeometricVerifierAlgorithm algorithm, uint32_t verifier_version, + unsigned char bytes[LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE]) { + if (!bytes) + return false; + std::memset(bytes, 0, LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE); + if (!lardon3d_geometric_verifier_parameters_valid(p) || + verifier_version == 0 || + (algorithm != LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC && + algorithm != LARDON3D_GEOMETRIC_ALGORITHM_USAC_DEFAULT)) + return false; + std::memcpy(bytes, "L3DGVFP1", 8); + put_u32(bytes + 8, 1); + put_u32(bytes + 12, LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL); + put_u32(bytes + 16, verifier_version); + put_u32(bytes + 20, static_cast(algorithm)); + put_u64(bytes + 24, double_bits(p->threshold_pixels)); + put_u64(bytes + 32, double_bits(p->confidence)); + put_u32(bytes + 40, p->max_iterations); + put_u32(bytes + 44, p->min_inlier_count); + put_u64(bytes + 48, double_bits(normalized_zero(p->min_inlier_ratio))); + put_u32(bytes + 56, p->seed_policy_version); + put_u32(bytes + 60, p->canonicalization_version); + bytes[64] = 2; // Point2d. + bytes[65] = cv::SAMPLING_UNIFORM; + bytes[66] = algorithm == LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC + ? cv::SCORE_METHOD_MAGSAC + : cv::SCORE_METHOD_MSAC; + bytes[67] = 0; // isParallel=false. + bytes[68] = cv::LOCAL_OPTIM_INNER_LO; + put_u32(bytes + 69, 5); + put_u32(bytes + 73, 14); + bytes[77] = cv::NEIGH_GRID; + bytes[78] = cv::COV_POLISHER; + put_u32(bytes + 79, 3); + bytes[83] = 0; + return true; +} + +extern "C" void lardon3d_geometric_verifier_fingerprint( + const Lardon3DGeometricVerifierParameters *p, unsigned char output[32]) { + if (!output) + return; + std::memset(output, 0, 32); + unsigned char bytes[LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE]; + if (!lardon3d_geometric_verifier_fingerprint_bytes( + p, LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC, + LARDON3D_GEOMETRIC_VERIFIER_VERSION, bytes)) + return; + (void)digest(bytes, sizeof(bytes), output); +} + +extern "C" uint32_t +lardon3d_geometric_verifier_seed(const unsigned char match_sha256[32], + const unsigned char fingerprint[32]) { + if (!match_sha256 || !fingerprint) + return 0; + unsigned char bytes[72]; + std::memcpy(bytes, "L3DGVSE1", 8); + std::memcpy(bytes + 8, match_sha256, 32); + std::memcpy(bytes + 40, fingerprint, 32); + unsigned char hash[32]; + if (!digest(bytes, sizeof(bytes), hash)) + return 0; + uint32_t value = static_cast(hash[0]) | + static_cast(hash[1]) << 8U | + static_cast(hash[2]) << 16U | + static_cast(hash[3]) << 24U; + return value & 0x7fffffffU; +} + +extern "C" bool lardon3d_geometric_verifier_canonicalize(double model[9]) { + if (!model) + return false; + double norm = 0.0; + size_t pivot = 0; + for (size_t index = 0; index < 9; ++index) { + if (!std::isfinite(model[index])) + return false; + norm = std::hypot(norm, model[index]); + if (std::fabs(model[index]) > std::fabs(model[pivot])) + pivot = index; + } + if (!std::isfinite(norm) || norm == 0.0) + return false; + const double sign = model[pivot] < 0.0 ? -1.0 : 1.0; + for (size_t index = 0; index < 9; ++index) + model[index] = normalized_zero(sign * model[index] / norm); + return true; +} + +extern "C" Lardon3DGeometricVerifierResult +lardon3d_geometric_verifier_verify_and_publish( + const char *project_path, Lardon3DProjectDb *db, uint64_t match_id, + const Lardon3DGeometricVerifierParameters *p, + Lardon3DProjectDbGeometricVerificationResult *result, bool *reused) { + if (!project_path || !db || match_id == 0 || !p || !result || !reused || + !lardon3d_geometric_verifier_parameters_valid(p)) + return LARDON3D_GEOMETRIC_VERIFIER_INVALID_ARGUMENT; + *reused = false; + unsigned char fingerprint[32]; + lardon3d_geometric_verifier_fingerprint(p, fingerprint); + Lardon3DProjectDbResult found = + lardon3d_project_db_find_geometric_verification_result( + db, match_id, LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, + LARDON3D_GEOMETRIC_VERIFIER_VERSION, fingerprint, result); + if (found == LARDON3D_PROJECT_DB_OK) { + *reused = true; + return LARDON3D_GEOMETRIC_VERIFIER_OK; + } + if (found != LARDON3D_PROJECT_DB_NOT_FOUND) + return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; + try { + Lardon3DProjectDbMatchResult parent; + if (lardon3d_project_db_load_match_result(db, match_id, &parent) != + LARDON3D_PROJECT_DB_OK || + parent.result_status != LARDON3D_MATCH_RESULT_STATUS_MATCHED || + !parent.has_match_asset) + return LARDON3D_GEOMETRIC_VERIFIER_NOT_FOUND; + Lardon3DProjectDbFeatureSet set_a, set_b; + if (lardon3d_project_db_load_feature_set( + db, parent.feature_set_id_a, &set_a) != LARDON3D_PROJECT_DB_OK || + lardon3d_project_db_load_feature_set(db, parent.feature_set_id_b, + &set_b) != LARDON3D_PROJECT_DB_OK) + return LARDON3D_GEOMETRIC_VERIFIER_NOT_FOUND; + char path[4096]; + Lardon3DMatchFileHeader header; + if (!join_path(path, project_path, parent.match_asset_path) || + lardon3d_match_file_validate_asset( + path, parent.match_asset_sha256, parent.match_asset_size_bytes, + &header, set_a.feature_set_id, set_b.feature_set_id, + set_a.feature_count, + set_b.feature_count) != LARDON3D_MATCH_FILE_OK || + header.match_count != parent.match_count) + return LARDON3D_GEOMETRIC_VERIFIER_CORRUPT; + std::vector entries(parent.match_count); + int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + uint32_t count = 0; + bool read_ok = + fd >= 0 && + lardon3d_match_file_read(fd, &header, entries.data(), entries.size(), + &count, set_a.feature_set_id, + set_b.feature_set_id, set_a.feature_count, + set_b.feature_count) == LARDON3D_MATCH_FILE_OK; + if (fd >= 0) + (void)close(fd); + if (!read_ok || count != parent.match_count) + return LARDON3D_GEOMETRIC_VERIFIER_CORRUPT; + std::vector keypoints_a, keypoints_b; + if (!read_points(project_path, set_a, keypoints_a) || + !read_points(project_path, set_b, keypoints_b)) + return LARDON3D_GEOMETRIC_VERIFIER_CORRUPT; + std::vector points_a, points_b; + points_a.reserve(count); + points_b.reserve(count); + for (const auto &entry : entries) { + points_a.emplace_back(keypoints_a[entry.feature_index_a].x, + keypoints_a[entry.feature_index_a].y); + points_b.emplace_back(keypoints_b[entry.feature_index_b].x, + keypoints_b[entry.feature_index_b].y); + } + cv::Mat mask; + cv::Mat model; + if (count >= LARDON3D_GEOMETRIC_VERIFIER_MINIMUM_MATCHES) { + uint32_t seed = lardon3d_geometric_verifier_seed( + parent.match_asset_sha256, fingerprint); + if (!estimate_fundamental(points_a, points_b, p, seed, model, mask)) + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + } + std::vector bitset((count + 7U) / 8U, 0); + uint32_t inliers = 0; + double canonical[9] = {}; + if (!model.empty()) { + if (model.rows != 3 || model.cols != 3 || model.channels() != 1 || + model.total() != 9 || mask.type() != CV_8U || mask.total() != count) + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + for (uint32_t index = 0; index < count; ++index) { + unsigned char value = mask.ptr()[index]; + if (value > 1) + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + if (value) { + bitset[index / 8U] |= static_cast(1U << (index % 8U)); + ++inliers; + } + } + cv::Mat doubles; + model.convertTo(doubles, CV_64F); + std::memcpy(canonical, doubles.ptr(), sizeof(canonical)); + if (!lardon3d_geometric_verifier_canonicalize(canonical)) + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + } else if (!mask.empty()) { + if (mask.type() != CV_8U || mask.total() != count) + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + for (uint32_t index = 0; index < count; ++index) + if (mask.ptr()[index] != 0) + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + } + bool accepted = !model.empty() && inliers >= p->min_inlier_count && + static_cast(inliers) / count >= p->min_inlier_ratio; + int64_t now = static_cast(std::time(nullptr)); + if (now < 0) + return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TESTING + const char *publication_failure = + std::getenv("LARDON3D_TEST_GEOMETRIC_PUBLICATION_FAILURE"); + if (publication_failure && std::strcmp(publication_failure, "1") == 0) + return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; +#endif + Lardon3DProjectDbResult created = + lardon3d_project_db_create_geometric_verification_result( + db, match_id, LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, + LARDON3D_GEOMETRIC_VERIFIER_VERSION, fingerprint, + accepted ? LARDON3D_GEOMETRIC_VERIFIED + : LARDON3D_GEOMETRIC_REJECTED, + inliers, bitset.data(), bitset.size(), + accepted ? canonical : nullptr, now, result); + if (created == LARDON3D_PROJECT_DB_CONSTRAINT) { + Lardon3DProjectDbResult concurrent = + lardon3d_project_db_find_geometric_verification_result( + db, match_id, LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, + LARDON3D_GEOMETRIC_VERIFIER_VERSION, fingerprint, result); + if (concurrent == LARDON3D_PROJECT_DB_OK) { + *reused = true; + return LARDON3D_GEOMETRIC_VERIFIER_OK; + } + return LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; + } + return created == LARDON3D_PROJECT_DB_OK + ? LARDON3D_GEOMETRIC_VERIFIER_OK + : LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR; + } catch (const std::bad_alloc &) { + return LARDON3D_GEOMETRIC_VERIFIER_OUT_OF_MEMORY; + } catch (const cv::Exception &) { + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + } catch (...) { + return LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR; + } +} diff --git a/src/geometric_verifier_task.c b/src/geometric_verifier_task.c new file mode 100644 index 0000000..54ab35a --- /dev/null +++ b/src/geometric_verifier_task.c @@ -0,0 +1,321 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +enum { + GEOMETRIC_VERIFIER_PAGE_CAPACITY = + LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH + 1, + GEOMETRIC_VERIFIER_MEMORY_BYTES = 4 * 1024 * 1024, + GEOMETRIC_VERIFIER_CPU_THREADS = 1, +}; + +typedef struct { + char project_path[PATH_MAX]; + Lardon3DProjectDb *database; + Lardon3DResourceGovernor *governor; + Lardon3DProjectDbGeometricVerifierTask durable; +} Lardon3DGeometricVerifierTaskContext; + +static void destroy_context(void *userdata) { free(userdata); } + +static Lardon3DGeometricVerifierParameters +parameters_from_durable(const Lardon3DProjectDbGeometricVerifierTask *durable) { + return (Lardon3DGeometricVerifierParameters){ + .threshold_pixels = durable->threshold_pixels, + .confidence = durable->confidence, + .max_iterations = durable->max_iterations, + .min_inlier_count = durable->min_inlier_count, + .min_inlier_ratio = durable->min_inlier_ratio, + .seed_policy_version = durable->seed_policy_version, + .canonicalization_version = durable->canonicalization_version, + }; +} + +static void runtime_state(const Lardon3DGeometricVerifierTaskContext *context, + Lardon3DAppState *state) { + lardon3d_app_state_init(state); + state->project_loaded = true; + state->project_db = context->database; + state->resource_governor = context->governor; + (void)snprintf(state->project_path, sizeof(state->project_path), "%s", + context->project_path); +} + +static void finished_callback(const Lardon3DTask *task, void *userdata) { +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + const char *skip = getenv("LARDON3D_TEST_GEOMETRIC_SKIP_FINISHED_CHECKPOINT"); + if (skip && strcmp(skip, "1") == 0) { + return; + } +#endif + Lardon3DGeometricVerifierTaskContext *context = userdata; + Lardon3DAppState state; + runtime_state(context, &state); + (void)lardon3d_project_checkpoint_geometric_verifier_task(&state, task, + &context->durable); +} + +static uint64_t elapsed_ns(struct timespec begin, struct timespec end) { + uint64_t seconds = + end.tv_sec >= begin.tv_sec ? (uint64_t)(end.tv_sec - begin.tv_sec) : 0; + long nanoseconds = end.tv_nsec - begin.tv_nsec; + if (nanoseconds < 0 && seconds > 0) { + --seconds; + nanoseconds += 1000000000L; + } + return seconds * 1000000000ULL + (uint64_t)nanoseconds; +} + +static bool process_parent(Lardon3DTask *task, + Lardon3DGeometricVerifierTaskContext *context, + const Lardon3DProjectDbMatchResult *parent) { + if (!lardon3d_task_checkpoint(task)) { + return false; + } + if (parent->result_status != LARDON3D_MATCH_RESULT_STATUS_MATCHED || + parent->match_count == 0) { + return true; + } + Lardon3DGeometricVerifierParameters parameters = + parameters_from_durable(&context->durable); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + if (lardon3d_geometric_verifier_verify_and_publish( + context->project_path, context->database, parent->match_result_id, + ¶meters, &result, &reused) != LARDON3D_GEOMETRIC_VERIFIER_OK) { + return lardon3d_task_fail(task, "Vérification géométrique impossible."); + } +#ifdef LARDON3D_GEOMETRIC_VERIFIER_TASK_TESTING + const char *pause = getenv("LARDON3D_TEST_GEOMETRIC_PAUSE_AFTER_PUBLICATION"); + if (pause && strcmp(pause, "1") == 0) { + (void)lardon3d_task_pause(task); + return lardon3d_task_checkpoint(task); + } +#endif + return true; +} + +static bool checkpoint_batch(Lardon3DTask *task, + Lardon3DGeometricVerifierTaskContext *context, + unsigned int progress, uint64_t processed) { + char message[LARDON3D_TASK_MESSAGE_CAPACITY]; + (void)snprintf(message, sizeof(message), "Match Results traités:%lu", + (unsigned long)processed); + if (!lardon3d_task_set_progress(task, progress, message)) { + return false; + } + Lardon3DAppState state; + runtime_state(context, &state); + return lardon3d_project_checkpoint_geometric_verifier_task( + &state, task, &context->durable) == + LARDON3D_PROJECT_TASK_CHECKPOINT_OK; +} + +static bool run(Lardon3DTask *task, void *userdata) { + Lardon3DGeometricVerifierTaskContext *context = userdata; + uint64_t processed = 0; + for (;;) { + if (!lardon3d_task_checkpoint(task)) { + return false; + } + Lardon3DTaskExecutionContract contract; + if (!lardon3d_task_execution_contract(task, &contract) || + contract.batch_size < LARDON3D_GEOMETRIC_VERIFIER_TASK_MINIMUM_BATCH || + contract.batch_size > LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH) { + return lardon3d_task_fail(task, + "Contrat de lot Geometric Verifier invalide."); + } + Lardon3DProjectDbMatchResult page[GEOMETRIC_VERIFIER_PAGE_CAPACITY]; + size_t count = 0; + size_t capacity = contract.batch_size + 1; + if (lardon3d_project_db_list_match_results( + context->database, context->durable.after_match_result_id, page, + capacity, &count) != LARDON3D_PROJECT_DB_OK) { + return lardon3d_task_fail(task, "Pagination Match Result impossible."); + } + if (count == 0) { + return lardon3d_task_set_progress(task, 100, + "Vérification géométrique terminée."); + } + size_t batch_count = + count < contract.batch_size ? count : contract.batch_size; + struct timespec begin; + struct timespec end; + (void)clock_gettime(CLOCK_MONOTONIC, &begin); + for (size_t index = 0; index < batch_count; ++index) { + if (!process_parent(task, context, &page[index])) { + return false; + } + context->durable.after_match_result_id = page[index].match_result_id; + ++processed; + } + (void)clock_gettime(CLOCK_MONOTONIC, &end); + (void)lardon3d_resource_governor_record_batch( + context->governor, LARDON3D_RESOURCE_TASK_CPU, batch_count, + elapsed_ns(begin, end), 0); + bool exhausted = count <= contract.batch_size; + if (!checkpoint_batch(task, context, exhausted ? 100U : 99U, processed)) { + return lardon3d_task_fail(task, + "Checkpoint Geometric Verifier impossible."); + } + if (exhausted) { + return lardon3d_task_set_progress(task, 100, + "Vérification géométrique terminée."); + } + Lardon3DResourceReservation *reservation = NULL; + if (!lardon3d_task_sequence_break(task, context->governor, &reservation, + &contract)) { + return false; + } + } +} + +static Lardon3DGeometricVerifierTaskContext * +make_context(const Lardon3DTaskReconstructionContext *runtime, + const Lardon3DProjectDbGeometricVerifierTask *durable) { + if (!runtime || !runtime->project_path || !runtime->project_db || + !runtime->resource_governor || !durable) { + return NULL; + } + Lardon3DGeometricVerifierParameters parameters = + parameters_from_durable(durable); + unsigned char fingerprint[32]; + lardon3d_geometric_verifier_fingerprint(¶meters, fingerprint); + if (!lardon3d_geometric_verifier_parameters_valid(¶meters) || + memcmp(fingerprint, durable->parameter_fingerprint, + sizeof(fingerprint)) != 0) { + return NULL; + } + Lardon3DGeometricVerifierTaskContext *context = calloc(1, sizeof(*context)); + if (!context) { + return NULL; + } + int written = snprintf(context->project_path, sizeof(context->project_path), + "%s", runtime->project_path); + if (written <= 0 || (size_t)written >= sizeof(context->project_path)) { + free(context); + return NULL; + } + context->database = runtime->project_db; + context->governor = runtime->resource_governor; + context->durable = *durable; + return context; +} + +bool lardon3d_geometric_verifier_task_reconstruct( + const Lardon3DTaskDurableSnapshot *snapshot, void *userdata, + Lardon3DTaskKindBinding *binding) { + Lardon3DTaskReconstructionContext *runtime = userdata; + if (!snapshot || !runtime || !binding) { + return false; + } + Lardon3DProjectDbGeometricVerifierTask durable; + if (lardon3d_project_db_load_geometric_verifier_task( + runtime->project_db, snapshot->id, &durable) != + LARDON3D_PROJECT_DB_OK) { + return false; + } + Lardon3DGeometricVerifierTaskContext *context = + make_context(runtime, &durable); + if (!context) { + return false; + } + *binding = (Lardon3DTaskKindBinding){ + .callback = run, + .userdata = context, + .userdata_destroy = destroy_context, + .finished_callback = finished_callback, + .finished_userdata = context, + }; + return true; +} + +Lardon3DTask *lardon3d_project_create_geometric_verifier_task( + Lardon3DAppState *state, + const Lardon3DGeometricVerifierTaskConfiguration *configuration, + uint64_t *task_id) { + if (task_id) { + *task_id = 0; + } + if (!state || !state->project_loaded || !state->project_db || + !state->resource_governor || !configuration || !task_id || + !lardon3d_geometric_verifier_parameters_valid(&configuration->verifier)) { + return NULL; + } + uint64_t id = 0; + if (lardon3d_project_db_allocate_task_id(state->project_db, &id) != + LARDON3D_PROJECT_DB_OK) { + return NULL; + } + Lardon3DProjectDbGeometricVerifierTask durable = { + .task_id = id, + .threshold_pixels = configuration->verifier.threshold_pixels, + .confidence = configuration->verifier.confidence, + .max_iterations = configuration->verifier.max_iterations, + .min_inlier_count = configuration->verifier.min_inlier_count, + .min_inlier_ratio = configuration->verifier.min_inlier_ratio, + .seed_policy_version = configuration->verifier.seed_policy_version, + .canonicalization_version = + configuration->verifier.canonicalization_version, + }; + lardon3d_geometric_verifier_fingerprint(&configuration->verifier, + durable.parameter_fingerprint); + Lardon3DTaskReconstructionContext runtime = { + .project_path = state->project_path, + .project_db = state->project_db, + .resource_governor = state->resource_governor, + }; + Lardon3DGeometricVerifierTaskContext *context = + make_context(&runtime, &durable); + if (!context) { + return NULL; + } + const Lardon3DResourceEstimate estimate = { + .memory_fixed_bytes = GEOMETRIC_VERIFIER_MEMORY_BYTES, + .minimum_batch_size = LARDON3D_GEOMETRIC_VERIFIER_TASK_MINIMUM_BATCH, + .maximum_batch_size = LARDON3D_GEOMETRIC_VERIFIER_TASK_MAXIMUM_BATCH, + .desired_cpu_threads = GEOMETRIC_VERIFIER_CPU_THREADS, + .desired_io_slots = 1, + .task_class = LARDON3D_RESOURCE_TASK_CPU, + }; + Lardon3DTask *task = + lardon3d_task_create_typed("Geometric Verification", &estimate, + LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND, + LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND_VERSION, + run, context, destroy_context); + if (!task || !lardon3d_task_assign_id(task, id) || + !lardon3d_task_set_finished_callback(task, finished_callback, context) || + lardon3d_project_checkpoint_geometric_verifier_task( + state, task, &durable) != LARDON3D_PROJECT_TASK_CHECKPOINT_OK) { + lardon3d_task_destroy(task); + return NULL; + } + *task_id = id; + return task; +} + +bool lardon3d_project_enqueue_geometric_verifier_task( + Lardon3DAppState *state, + const Lardon3DGeometricVerifierTaskConfiguration *configuration, + uint64_t *task_id) { + if (!state || !state->task_queue) { + return false; + } + Lardon3DTask *task = lardon3d_project_create_geometric_verifier_task( + state, configuration, task_id); + if (!task) { + return false; + } + if (!lardon3d_task_queue_add(state->task_queue, task, NULL)) { + lardon3d_task_destroy(task); + return false; + } + return true; +} diff --git a/src/project.c b/src/project.c index 15438df..a1d164a 100644 --- a/src/project.c +++ b/src/project.c @@ -673,7 +673,8 @@ checkpoint_task_internal(Lardon3DAppState *state, const Lardon3DTask *task, const Lardon3DProjectDbSiftExtractTask *sift_parameters, const Lardon3DProjectDbVisualIndexUpdateTask *visual_parameters, const Lardon3DProjectDbCandidatePairGenerateTask *candidate_parameters, - const Lardon3DProjectDbMatcherTask *matcher_parameters) { + const Lardon3DProjectDbMatcherTask *matcher_parameters, + const Lardon3DProjectDbGeometricVerifierTask *geometric_parameters) { if (!state || !state->project_loaded || !state->project_db) { return LARDON3D_PROJECT_TASK_CHECKPOINT_NO_PROJECT; } @@ -736,6 +737,10 @@ checkpoint_task_internal(Lardon3DAppState *state, const Lardon3DTask *task, ? lardon3d_project_db_record_matcher_task( state->project_db, &snapshot, task_kind, task_kind_version, &checkpoint, matcher_parameters, now.tv_sec) + : geometric_parameters + ? lardon3d_project_db_record_geometric_verifier_task( + state->project_db, &snapshot, task_kind, task_kind_version, &checkpoint, + geometric_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) { @@ -751,7 +756,7 @@ 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); + return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL); } Lardon3DProjectTaskCheckpointResult @@ -761,7 +766,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); + state, task, source_path, scanset_id, NULL, NULL, NULL, NULL, NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_feature_extract_task( @@ -770,14 +775,14 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_feature_extract_ if (!parameters) { return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } - return checkpoint_task_internal(state, task, NULL, 0, parameters, NULL, NULL, NULL, NULL); + return checkpoint_task_internal(state, task, NULL, 0, parameters, NULL, NULL, NULL, NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_sift_extract_task( Lardon3DAppState *state, const Lardon3DTask *task, 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); + return checkpoint_task_internal(state, task, NULL, 0, NULL, parameters, NULL, NULL, NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_visual_index_update_task( @@ -786,7 +791,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_visual_index_upd if (!parameters) { return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } - return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, parameters, NULL, NULL); + return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, parameters, NULL, NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_candidate_pair_generate_task( @@ -795,7 +800,7 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_candidate_pair_g if (!parameters) { return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } - return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, parameters, NULL); + return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, parameters, NULL, NULL); } Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_matcher_task( @@ -804,7 +809,17 @@ Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_matcher_task( if (!parameters) { return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; } - return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, parameters); + return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, parameters, NULL); +} + +Lardon3DProjectTaskCheckpointResult lardon3d_project_checkpoint_geometric_verifier_task( + Lardon3DAppState *state, const Lardon3DTask *task, + const Lardon3DProjectDbGeometricVerifierTask *parameters) { + if (!parameters) { + return LARDON3D_PROJECT_TASK_CHECKPOINT_INVALID_TASK; + } + return checkpoint_task_internal(state, task, NULL, 0, NULL, NULL, NULL, NULL, NULL, + parameters); } static bool coherent_recovery(const Lardon3DProjectDbTask *database_task, diff --git a/src/project_db.c b/src/project_db.c index 239d160..7c6d1e8 100644 --- a/src/project_db.c +++ b/src/project_db.c @@ -263,6 +263,19 @@ static const char schema_geometric_verification_v12[] = "CREATE INDEX geometric_verification_results_parent_idx ON " "geometric_verification_results(match_result_id,geometric_verification_result_id);"; +static const char schema_geometric_verifier_task_v13[] = + "CREATE TABLE geometric_verifier_tasks(" + "task_id INTEGER PRIMARY KEY REFERENCES tasks(task_id) ON DELETE CASCADE," + "after_match_result_id INTEGER NOT NULL CHECK(after_match_result_id>=0)," + "threshold_pixels REAL NOT NULL CHECK(threshold_pixels>0.0)," + "confidence REAL NOT NULL CHECK(confidence>0.0 AND confidence<1.0)," + "max_iterations INTEGER NOT NULL CHECK(max_iterations>0 AND max_iterations<=2147483647)," + "min_inlier_count INTEGER NOT NULL CHECK(min_inlier_count BETWEEN 1 AND 8192)," + "min_inlier_ratio REAL NOT NULL CHECK(min_inlier_ratio>=0.0 AND min_inlier_ratio<=1.0)," + "seed_policy_version INTEGER NOT NULL CHECK(seed_policy_version>0)," + "canonicalization_version INTEGER NOT NULL CHECK(canonicalization_version>0)," + "parameter_fingerprint BLOB NOT NULL CHECK(length(parameter_fingerprint)=32));"; + 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 : ""); @@ -352,7 +365,8 @@ static Lardon3DProjectDbResult migrate(Lardon3DProjectDb *database, unsigned int } if (from_version != 0 && from_version != 1 && from_version != 2 && from_version != 3 && 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 != 8 && from_version != 9 && from_version != 10 && from_version != 11 && + from_version != 12) { return LARDON3D_PROJECT_DB_CORRUPT; } Lardon3DProjectDbResult result = execute(database, "BEGIN IMMEDIATE", "begin migration"); @@ -609,6 +623,22 @@ static Lardon3DProjectDbResult migrate(Lardon3DProjectDb *database, unsigned int "finish schema v12 migration"); } } + if (result == LARDON3D_PROJECT_DB_OK && from_version < 13) { + result = execute(database, schema_geometric_verifier_task_v13, + "migrate schema v12 to v13"); +#ifdef LARDON3D_PROJECT_DB_TESTING + const char *forced_failure = getenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V13"); + if (result == LARDON3D_PROJECT_DB_OK && forced_failure && strcmp(forced_failure, "1") == 0) { + result = execute(database, "INSERT INTO missing_test_table VALUES(1)", + "forced migration v13 failure"); + } +#endif + if (result == LARDON3D_PROJECT_DB_OK) { + result = execute(database, + "UPDATE metadata SET value=13 WHERE key='schema_version' AND value=12", + "finish schema v13 migration"); + } + } if (result == LARDON3D_PROJECT_DB_OK) { result = execute(database, "COMMIT", "commit migration"); } @@ -724,7 +754,8 @@ Lardon3DProjectDbResult lardon3d_project_db_open(const char *path, Lardon3DProje "candidate_pairs", "matcher_tasks", "match_results", - "geometric_verification_results"}; + "geometric_verification_results", + "geometric_verifier_tasks"}; for (size_t index = 0; index < sizeof(required) / sizeof(required[0]) && result == LARDON3D_PROJECT_DB_OK; ++index) { @@ -916,6 +947,7 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps const Lardon3DProjectDbVisualIndexUpdateTask *visual, const Lardon3DProjectDbCandidatePairGenerateTask *candidate_pair, const Lardon3DProjectDbMatcherTask *matcher, + const Lardon3DProjectDbGeometricVerifierTask *geometric_verifier, int64_t updated_at) { bool typed = task_kind != NULL; if (!database || !valid_durable_task(snapshot, updated_at) || @@ -959,6 +991,23 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps matcher->feature_extractor_version == 0 || matcher->matcher_kind < 0 || matcher->matcher_kind > 2 || !isfinite(matcher->ratio_threshold) || matcher->ratio_threshold <= 0.0F || matcher->ratio_threshold >= 1.0F)) || + (geometric_verifier && + (!valid_task_id(geometric_verifier->task_id) || + geometric_verifier->task_id != snapshot->id || + geometric_verifier->after_match_result_id > INT64_MAX || + !isfinite(geometric_verifier->threshold_pixels) || + geometric_verifier->threshold_pixels <= 0.0 || + !isfinite(geometric_verifier->confidence) || + geometric_verifier->confidence <= 0.0 || geometric_verifier->confidence >= 1.0 || + geometric_verifier->max_iterations == 0 || + geometric_verifier->max_iterations > INT_MAX || + geometric_verifier->min_inlier_count == 0 || + geometric_verifier->min_inlier_count > 8192 || + !isfinite(geometric_verifier->min_inlier_ratio) || + geometric_verifier->min_inlier_ratio < 0.0 || + geometric_verifier->min_inlier_ratio > 1.0 || + geometric_verifier->seed_policy_version == 0 || + geometric_verifier->canonicalization_version == 0)) || (checkpoint && !valid_checkpoint(checkpoint))) { return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } @@ -1155,6 +1204,42 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps } } } + if (result == LARDON3D_PROJECT_DB_OK && geometric_verifier) { + result = prepare( + database, + "INSERT INTO geometric_verifier_tasks(task_id,after_match_result_id,threshold_pixels," + "confidence,max_iterations,min_inlier_count,min_inlier_ratio,seed_policy_version," + "canonicalization_version,parameter_fingerprint) VALUES(?1,?2,?3,?4,?5,?6,?7,?8," + "?9,?10) ON CONFLICT(task_id) DO UPDATE SET after_match_result_id=" + "excluded.after_match_result_id WHERE geometric_verifier_tasks.threshold_pixels=" + "excluded.threshold_pixels AND geometric_verifier_tasks.confidence=excluded.confidence " + "AND geometric_verifier_tasks.max_iterations=excluded.max_iterations AND " + "geometric_verifier_tasks.min_inlier_count=excluded.min_inlier_count AND " + "geometric_verifier_tasks.min_inlier_ratio=excluded.min_inlier_ratio AND " + "geometric_verifier_tasks.seed_policy_version=excluded.seed_policy_version AND " + "geometric_verifier_tasks.canonicalization_version=excluded.canonicalization_version " + "AND geometric_verifier_tasks.parameter_fingerprint=excluded.parameter_fingerprint", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)geometric_verifier->task_id); + sqlite3_bind_int64(statement, 2, + (sqlite3_int64)geometric_verifier->after_match_result_id); + sqlite3_bind_double(statement, 3, geometric_verifier->threshold_pixels); + sqlite3_bind_double(statement, 4, geometric_verifier->confidence); + sqlite3_bind_int64(statement, 5, geometric_verifier->max_iterations); + sqlite3_bind_int64(statement, 6, geometric_verifier->min_inlier_count); + sqlite3_bind_double(statement, 7, geometric_verifier->min_inlier_ratio); + sqlite3_bind_int64(statement, 8, geometric_verifier->seed_policy_version); + sqlite3_bind_int64(statement, 9, geometric_verifier->canonicalization_version); + sqlite3_bind_blob(statement, 10, geometric_verifier->parameter_fingerprint, 32, + SQLITE_TRANSIENT); + result = step_done(database, statement, "upsert geometric verifier task"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) { + copy_error(database->error, "Configuration Geometric Verifier de tâche immuable."); + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + } + } if (result == LARDON3D_PROJECT_DB_OK && sift) { result = prepare( database, @@ -1224,7 +1309,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, updated_at); + NULL, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_image_import_task( @@ -1235,7 +1320,8 @@ Lardon3DProjectDbResult lardon3d_project_db_record_image_import_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, - source_path, scanset_id, NULL, NULL, NULL, NULL, NULL, updated_at); + source_path, scanset_id, NULL, NULL, NULL, NULL, NULL, NULL, + updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_feature_extract_task( @@ -1246,7 +1332,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, updated_at); + parameters, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_sift_extract_task( @@ -1256,7 +1342,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, updated_at); + NULL, parameters, NULL, NULL, NULL, NULL, updated_at); } static bool read_task(sqlite3_stmt *statement, Lardon3DProjectDbTask *task) { @@ -3238,7 +3324,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, updated_at); + NULL, parameters, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_visual_index_update_task( @@ -3285,7 +3371,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, updated_at); + NULL, NULL, parameters, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_candidate_pair_generate_task( @@ -3341,7 +3427,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, updated_at); + NULL, NULL, NULL, parameters, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_matcher_task( @@ -3390,6 +3476,73 @@ Lardon3DProjectDbResult lardon3d_project_db_load_matcher_task( return result; } +Lardon3DProjectDbResult lardon3d_project_db_record_geometric_verifier_task( + Lardon3DProjectDb *db, const Lardon3DTaskDurableSnapshot *snapshot, + const char *kind, uint32_t version, + const Lardon3DProjectDbCheckpoint *checkpoint, + const Lardon3DProjectDbGeometricVerifierTask *parameters, int64_t updated_at) { + if (!snapshot || !parameters || parameters->task_id != snapshot->id) { + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + } + return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, + NULL, NULL, NULL, NULL, parameters, updated_at); +} + +Lardon3DProjectDbResult lardon3d_project_db_load_geometric_verifier_task( + Lardon3DProjectDb *db, uint64_t task_id, + Lardon3DProjectDbGeometricVerifierTask *parameters) { + if (!db || !valid_task_id(task_id) || !parameters) { + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + } + memset(parameters, 0, sizeof(*parameters)); + (void)pthread_mutex_lock(&db->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + db, + "SELECT after_match_result_id,threshold_pixels,confidence,max_iterations," + "min_inlier_count,min_inlier_ratio,seed_policy_version,canonicalization_version," + "parameter_fingerprint FROM geometric_verifier_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); + sqlite3_int64 after = sqlite3_column_int64(statement, 0); + double threshold = sqlite3_column_double(statement, 1); + double confidence = sqlite3_column_double(statement, 2); + sqlite3_int64 iterations = sqlite3_column_int64(statement, 3); + sqlite3_int64 min_count = sqlite3_column_int64(statement, 4); + double min_ratio = sqlite3_column_double(statement, 5); + sqlite3_int64 seed_version = sqlite3_column_int64(statement, 6); + sqlite3_int64 canonical_version = sqlite3_column_int64(statement, 7); + if (code == SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_NOT_FOUND; + } else if (code != SQLITE_ROW || after < 0 || !isfinite(threshold) || threshold <= 0.0 || + !isfinite(confidence) || confidence <= 0.0 || confidence >= 1.0 || + iterations <= 0 || iterations > INT_MAX || min_count <= 0 || + min_count > 8192 || !isfinite(min_ratio) || min_ratio < 0.0 || + min_ratio > 1.0 || seed_version <= 0 || seed_version > UINT32_MAX || + canonical_version <= 0 || canonical_version > UINT32_MAX || + sqlite3_column_bytes(statement, 8) != LARDON3D_PROJECT_DB_SHA256_SIZE) { + result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + parameters->task_id = task_id; + parameters->after_match_result_id = (uint64_t)after; + parameters->threshold_pixels = threshold; + parameters->confidence = confidence; + parameters->max_iterations = (uint32_t)iterations; + parameters->min_inlier_count = (uint32_t)min_count; + parameters->min_inlier_ratio = min_ratio; + parameters->seed_policy_version = (uint32_t)seed_version; + parameters->canonicalization_version = (uint32_t)canonical_version; + memcpy(parameters->parameter_fingerprint, sqlite3_column_blob(statement, 8), + LARDON3D_PROJECT_DB_SHA256_SIZE); + } + sqlite3_finalize(statement); + } + (void)pthread_mutex_unlock(&db->mutex); + return result; +} + static bool read_match_result(sqlite3_stmt *statement, Lardon3DProjectDbMatchResult *result) { sqlite3_int64 id = sqlite3_column_int64(statement, 0); diff --git a/src/task_kinds.c b/src/task_kinds.c index 74ae62b..2030490 100644 --- a/src/task_kinds.c +++ b/src/task_kinds.c @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -42,6 +43,11 @@ const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_production(void) { .kind = LARDON3D_MATCHER_TASK_KIND, .kind_version = LARDON3D_MATCHER_TASK_KIND_VERSION, .reconstruct = lardon3d_matcher_task_reconstruct, + }, + { + .kind = LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND, + .kind_version = LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND_VERSION, + .reconstruct = lardon3d_geometric_verifier_task_reconstruct, }}; static const Lardon3DTaskKindRegistry registry = { .descriptors = descriptors, diff --git a/tests/benchmark_geometric_verifier.cpp b/tests/benchmark_geometric_verifier.cpp new file mode 100644 index 0000000..ac1fadc --- /dev/null +++ b/tests/benchmark_geometric_verifier.cpp @@ -0,0 +1,455 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct Generator { + uint64_t state; + + uint32_t next_u32() { + state ^= state >> 12; + state ^= state << 25; + state ^= state >> 27; + return static_cast((state * 0x2545f4914f6cdd1dULL) >> 32); + } + + double uniform() { return static_cast(next_u32()) / 4294967296.0; } + + double normal() { + const double u1 = std::max(uniform(), 1e-12); + const double u2 = uniform(); + return std::sqrt(-2.0 * std::log(u1)) * std::cos(2.0 * CV_PI * u2); + } +}; + +enum class Geometry { + Healthy, + WeakBaseline, + WideBaseline, + Concentrated, + NearCollinear, + Planar, + RotationDominant, + Duplicated, +}; + +struct Scenario { + const char *name; + Geometry geometry; + int width; + int height; + int count; + double noise; + double outlier_ratio; +}; + +struct Corpus { + std::vector first; + std::vector second; + std::vector truth; + cv::Mat fundamental; +}; + +struct Measurement { + bool model_found = false; + double precision = 0.0; + double recall = 0.0; + double median_sampson = 0.0; + double elapsed_ms = 0.0; + double median_ms = 0.0; + double p95_ms = 0.0; + double worst_ms = 0.0; + uint64_t output_hash = 0; + int inlier_count = 0; +}; + +enum class Method { + Classic, + UsacDefault, + UsacMagsac, + UsacAccurate, + SeededMagsac, +}; + +const char *method_name(Method method) { + switch (method) { + case Method::Classic: + return "FM_RANSAC"; + case Method::UsacDefault: + return "USAC_DEFAULT"; + case Method::UsacMagsac: + return "USAC_MAGSAC"; + case Method::UsacAccurate: + return "USAC_ACCURATE"; + case Method::SeededMagsac: + return "USAC_MAGSAC_LOCAL_SEED"; + } + return "UNKNOWN"; +} + +cv::Mat rotation_y(double angle) { + return (cv::Mat_(3, 3) << std::cos(angle), 0.0, std::sin(angle), 0.0, + 1.0, 0.0, -std::sin(angle), 0.0, std::cos(angle)); +} + +cv::Mat skew(const cv::Vec3d &translation) { + return (cv::Mat_(3, 3) << 0.0, -translation[2], translation[1], + translation[2], 0.0, -translation[0], -translation[1], translation[0], + 0.0); +} + +cv::Point2d project(const cv::Mat &rotation, const cv::Vec3d &translation, + const cv::Vec3d &point, double focal, double cx, + double cy) { + cv::Mat transformed = rotation * cv::Mat(point) + cv::Mat(translation); + const double x = transformed.at(0) / transformed.at(2); + const double y = transformed.at(1) / transformed.at(2); + return {focal * x + cx, focal * y + cy}; +} + +Corpus generate(const Scenario &scenario, uint64_t seed) { + Generator generator{seed}; + const double focal = + 0.85 * static_cast(std::max(scenario.width, scenario.height)); + const double cx = 0.5 * scenario.width; + const double cy = 0.5 * scenario.height; + double baseline = 0.35; + double angle = 0.08; + if (scenario.geometry == Geometry::WeakBaseline) + baseline = 0.025; + if (scenario.geometry == Geometry::WideBaseline) + baseline = 1.0; + if (scenario.geometry == Geometry::RotationDominant) { + baseline = 0.005; + angle = 0.25; + } + const cv::Mat rotation = rotation_y(angle); + const cv::Vec3d translation(baseline, 0.015, 0.01); + const cv::Mat intrinsic = + (cv::Mat_(3, 3) << focal, 0.0, cx, 0.0, focal, cy, 0.0, 0.0, 1.0); + const cv::Mat fundamental = + intrinsic.inv().t() * skew(translation) * rotation * intrinsic.inv(); + + Corpus corpus; + corpus.fundamental = fundamental; + corpus.first.reserve(scenario.count); + corpus.second.reserve(scenario.count); + corpus.truth.assign(scenario.count, 1); + for (int index = 0; index < scenario.count; ++index) { + double x = 3.0 * (generator.uniform() - 0.5); + double y = 2.0 * (generator.uniform() - 0.5); + double z = 4.0 + 5.0 * generator.uniform(); + if (scenario.geometry == Geometry::Concentrated) { + x *= 0.12; + y *= 0.12; + } else if (scenario.geometry == Geometry::NearCollinear) { + y = 0.015 * x + 0.002 * generator.normal(); + } else if (scenario.geometry == Geometry::Planar) { + z = 6.0; + } else if (scenario.geometry == Geometry::Duplicated && + index > scenario.count / 2) { + x = 0.1; + y = -0.1; + z = 6.0; + } + const cv::Vec3d point(x, y, z); + cv::Point2d first = + project(cv::Mat::eye(3, 3, CV_64F), cv::Vec3d(), point, focal, cx, cy); + cv::Point2d second = project(rotation, translation, point, focal, cx, cy); + first.x += scenario.noise * generator.normal(); + first.y += scenario.noise * generator.normal(); + second.x += scenario.noise * generator.normal(); + second.y += scenario.noise * generator.normal(); + corpus.first.push_back(first); + corpus.second.push_back(second); + } + const int outlier_count = + static_cast(std::lround(scenario.count * scenario.outlier_ratio)); + for (int index = 0; index < outlier_count; ++index) { + const int target = scenario.count - 1 - index; + corpus.second[target] = {scenario.width * generator.uniform(), + scenario.height * generator.uniform()}; + corpus.truth[target] = 0; + } + return corpus; +} + +double sampson(const cv::Mat &fundamental, const cv::Point2d &first, + const cv::Point2d &second) { + const cv::Mat x1 = (cv::Mat_(3, 1) << first.x, first.y, 1.0); + const cv::Mat x2 = (cv::Mat_(3, 1) << second.x, second.y, 1.0); + const cv::Mat line2 = fundamental * x1; + const cv::Mat line1 = fundamental.t() * x2; + const double residual = x2.dot(line2); + const double denominator = line1.at(0) * line1.at(0) + + line1.at(1) * line1.at(1) + + line2.at(0) * line2.at(0) + + line2.at(1) * line2.at(1); + return denominator > 0.0 ? residual * residual / denominator : INFINITY; +} + +uint64_t hash_output(const cv::Mat &model, const cv::Mat &mask) { + uint64_t hash = 1469598103934665603ULL; + auto mix = [&](const unsigned char *bytes, size_t size) { + for (size_t index = 0; index < size; ++index) { + hash ^= bytes[index]; + hash *= 1099511628211ULL; + } + }; + if (!model.empty()) { + cv::Mat doubles; + model.convertTo(doubles, CV_64F); + mix(doubles.ptr(), doubles.total() * doubles.elemSize()); + } + if (!mask.empty()) + mix(mask.ptr(), mask.total() * mask.elemSize()); + return hash; +} + +cv::Mat estimate(Method method, const std::vector &first, + const std::vector &second, double threshold, + int seed, cv::Mat &mask) { + if (method == Method::SeededMagsac) { + cv::UsacParams params; + params.confidence = 0.999; + params.maxIterations = 5000; + params.randomGeneratorState = seed; + params.sampler = cv::SAMPLING_UNIFORM; + params.score = cv::SCORE_METHOD_MAGSAC; + params.threshold = threshold; + params.isParallel = false; + return cv::findFundamentalMat(first, second, mask, params); + } + int flag = cv::FM_RANSAC; + if (method == Method::UsacDefault) + flag = cv::USAC_DEFAULT; + if (method == Method::UsacMagsac) + flag = cv::USAC_MAGSAC; + if (method == Method::UsacAccurate) + flag = cv::USAC_ACCURATE; + return cv::findFundamentalMat(first, second, flag, threshold, 0.999, 5000, + mask); +} + +Measurement measure(Method method, const Corpus &corpus, double threshold, + int seed, bool use_float) { + cv::Mat mask; + const auto start = std::chrono::steady_clock::now(); + cv::Mat model; + try { + if (use_float) { + std::vector first(corpus.first.begin(), corpus.first.end()); + std::vector second(corpus.second.begin(), + corpus.second.end()); + model = + estimate(method, std::vector(first.begin(), first.end()), + std::vector(second.begin(), second.end()), + threshold, seed, mask); + } else { + model = + estimate(method, corpus.first, corpus.second, threshold, seed, mask); + } + } catch (const cv::Exception &) { + model.release(); + mask.release(); + } + const auto stop = std::chrono::steady_clock::now(); + Measurement result; + result.elapsed_ms = + std::chrono::duration(stop - start).count(); + result.model_found = + model.rows == 3 && model.cols == 3 && mask.total() == corpus.truth.size(); + result.output_hash = hash_output(model, mask); + if (!result.model_found) + return result; + int true_positive = 0; + int false_positive = 0; + int false_negative = 0; + std::vector errors; + for (size_t index = 0; index < corpus.truth.size(); ++index) { + const bool selected = mask.ptr()[index] != 0; + if (selected) + ++result.inlier_count; + if (selected && corpus.truth[index]) + ++true_positive; + if (selected && !corpus.truth[index]) + ++false_positive; + if (!selected && corpus.truth[index]) + ++false_negative; + if (corpus.truth[index]) + errors.push_back( + sampson(model, corpus.first[index], corpus.second[index])); + } + result.precision = true_positive + false_positive + ? static_cast(true_positive) / + (true_positive + false_positive) + : 0.0; + result.recall = true_positive + false_negative + ? static_cast(true_positive) / + (true_positive + false_negative) + : 0.0; + std::sort(errors.begin(), errors.end()); + result.median_sampson = errors.empty() ? 0.0 : errors[errors.size() / 2]; + return result; +} + +void print_measurement(const Scenario &scenario, Method method, + double threshold, const Measurement &measurement, + bool repeatable, const char *precision) { + std::printf( + "%s,%s,%s,%d,%.2f,%.2f,%.2f,%d,%.6f,%.6f,%.6f,%.6f,%.6f,%.6f,%s\n", + scenario.name, method_name(method), precision, scenario.count, + scenario.noise, scenario.outlier_ratio, threshold, + measurement.model_found ? 1 : 0, measurement.precision, + measurement.recall, measurement.median_sampson, measurement.median_ms, + measurement.p95_ms, measurement.worst_ms, repeatable ? "yes" : "no"); +} + +} // namespace + +int main(int argc, char **argv) { + const std::array scenarios = { + {{"healthy_exact", Geometry::Healthy, 1920, 1080, 256, 0.0, 0.0}, + {"healthy_low", Geometry::Healthy, 1920, 1080, 256, 0.35, 0.1}, + {"healthy_medium", Geometry::Healthy, 1920, 1080, 1024, 0.75, 0.3}, + {"healthy_high", Geometry::Healthy, 4000, 3000, 1024, 1.5, 0.5}, + {"outliers_70", Geometry::Healthy, 1920, 1080, 1024, 0.75, 0.7}, + {"weak_baseline", Geometry::WeakBaseline, 1920, 1080, 256, 0.5, 0.3}, + {"wide_baseline", Geometry::WideBaseline, 4000, 3000, 256, 0.75, 0.3}, + {"concentrated", Geometry::Concentrated, 1920, 1080, 256, 0.5, 0.3}, + {"near_collinear", Geometry::NearCollinear, 1920, 1080, 256, 0.35, 0.3}, + {"planar", Geometry::Planar, 1920, 1080, 256, 0.5, 0.3}, + {"rotation_dominant", Geometry::RotationDominant, 1920, 1080, 256, 0.5, + 0.3}, + {"minimum_7", Geometry::Healthy, 1280, 720, 7, 0.0, 0.0}, + {"minimum_8", Geometry::Healthy, 1280, 720, 8, 0.0, 0.0}, + {"minimum_15", Geometry::Healthy, 1280, 720, 15, 0.5, 0.3}, + {"small_16", Geometry::Healthy, 1280, 720, 16, 0.5, 0.3}, + {"all_false", Geometry::Healthy, 1920, 1080, 256, 0.5, 1.0}, + {"duplicates", Geometry::Duplicated, 1920, 1080, 256, 0.35, 0.3}, + {"large_4096", Geometry::Healthy, 4000, 3000, 4096, 0.75, 0.5}, + {"large_8192", Geometry::Healthy, 4000, 3000, 8192, 0.75, 0.7}}}; + const std::array methods = { + Method::Classic, Method::UsacDefault, Method::UsacMagsac, + Method::UsacAccurate, Method::SeededMagsac}; + if (argc == 2 && std::strcmp(argv[1], "--params") == 0) { + const cv::UsacParams params; + std::printf("parallel=%d lo_iterations=%d lo_method=%d lo_sample=%d " + "neighbors=%d sampler=%d " + "score=%d polisher=%d polisher_iterations=%d\n", + params.isParallel ? 1 : 0, params.loIterations, params.loMethod, + params.loSampleSize, params.neighborsSearch, params.sampler, + params.score, params.final_polisher, + params.final_polisher_iterations); + return 0; + } + if (argc == 2 && std::strcmp(argv[1], "--determinism-only") == 0) { + const Scenario difficult{ + "cross_process", Geometry::Healthy, 4000, 3000, 1024, 0.75, 0.7}; + const Corpus corpus = generate(difficult, 0x6c6172646f6eULL); + for (Method method : methods) { + const Measurement result = + measure(method, corpus, 1.5, 0x4c334431, false); + std::printf("%s,%016llx\n", method_name(method), + static_cast(result.output_hash)); + } + return 0; + } + if (argc == 2 && std::strcmp(argv[1], "--acceptance-only") == 0) { + const std::array acceptance = { + {{"good_64", Geometry::Healthy, 1920, 1080, 64, 0.75, 0.3}, + {"good_256", Geometry::Healthy, 1920, 1080, 256, 0.75, 0.5}, + {"good_1024", Geometry::Healthy, 1920, 1080, 1024, 0.75, 0.7}, + {"false_64", Geometry::Healthy, 1920, 1080, 64, 0.75, 1.0}, + {"false_256", Geometry::Healthy, 1920, 1080, 256, 0.75, 1.0}, + {"false_1024", Geometry::Healthy, 1920, 1080, 1024, 0.75, 1.0}, + {"false_4096", Geometry::Healthy, 4000, 3000, 4096, 0.75, 1.0}, + {"weak_256", Geometry::WeakBaseline, 1920, 1080, 256, 0.75, 0.5}}}; + for (const Scenario &scenario : acceptance) { + const Corpus corpus = generate(scenario, 0x6c6172646f6eULL); + const Measurement result = + measure(Method::SeededMagsac, corpus, 1.5, 0x4c334431, false); + std::printf("%s,count=%d,ratio=%.6f,precision=%.6f,recall=%.6f\n", + scenario.name, result.inlier_count, + static_cast(result.inlier_count) / scenario.count, + result.precision, result.recall); + } + return 0; + } + if (argc == 2 && std::strcmp(argv[1], "--rank-only") == 0) { + const Scenario scenario{"rank", Geometry::Healthy, 4000, 3000, 8192, 0.75, + 0.7}; + const Corpus corpus = generate(scenario, 0x6c6172646f6eULL); + cv::Mat mask; + cv::Mat model = estimate(Method::SeededMagsac, corpus.first, corpus.second, + 1.5, 0x4c334431, mask); + cv::Mat singular_values; + cv::SVD::compute(model, singular_values); + std::printf("singular_values=%.17g,%.17g,%.17g\n", + singular_values.at(0), singular_values.at(1), + singular_values.at(2)); + return 0; + } + std::printf( + "# corpus=lardon3d-geometric-v1 compiler=%s opencv=%s threads=%d seed=%d " + "confidence=0.999 max_iterations=5000\n", + __VERSION__, CV_VERSION, cv::getNumThreads(), 0x4c334431); + std::printf("scenario,algorithm,points,count,noise,outliers,threshold,found," + "precision,recall," + "median_sampson,median_ms,p95_ms,worst_ms,repeatable_32x\n"); + for (const Scenario &scenario : scenarios) { + const Corpus corpus = generate(scenario, 0x6c6172646f6eULL); + for (Method method : methods) { + const Measurement first = measure(method, corpus, 1.5, 0x4c334431, false); + Measurement summary = first; + bool repeatable = true; + std::vector timings; + timings.reserve(32); + for (int repetition = 0; repetition < 32; ++repetition) { + const Measurement repeated = + measure(method, corpus, 1.5, 0x4c334431, false); + repeatable = repeatable && repeated.output_hash == first.output_hash; + timings.push_back(repeated.elapsed_ms); + } + std::sort(timings.begin(), timings.end()); + summary.median_ms = timings[timings.size() / 2]; + summary.p95_ms = timings[30]; + summary.worst_ms = timings.back(); + print_measurement(scenario, method, 1.5, summary, repeatable, "f64"); + } + } + const Scenario tuning{ + "threshold_tuning", Geometry::Healthy, 1920, 1080, 1024, 0.75, 0.5}; + const Corpus tuning_corpus = generate(tuning, 0x6c6172646f6eULL); + for (double threshold : {0.5, 1.0, 1.5, 2.0, 3.0}) { + const Measurement value = measure(Method::SeededMagsac, tuning_corpus, + threshold, 0x4c334431, false); + Measurement summary = value; + summary.median_ms = value.elapsed_ms; + summary.p95_ms = value.elapsed_ms; + summary.worst_ms = value.elapsed_ms; + print_measurement(tuning, Method::SeededMagsac, threshold, summary, true, + "f64"); + } + for (bool use_float : {true, false}) { + const Measurement value = measure(Method::SeededMagsac, tuning_corpus, 1.5, + 0x4c334431, use_float); + Measurement summary = value; + summary.median_ms = value.elapsed_ms; + summary.p95_ms = value.elapsed_ms; + summary.worst_ms = value.elapsed_ms; + print_measurement(tuning, Method::SeededMagsac, 1.5, summary, true, + use_float ? "f32" : "f64"); + } + return 0; +} diff --git a/tests/test_geometric_verification.c b/tests/test_geometric_verification.c index 567aadd..784ae43 100644 --- a/tests/test_geometric_verification.c +++ b/tests/test_geometric_verification.c @@ -157,6 +157,7 @@ static bool create_v11_database(const char *path) { lardon3d_project_db_close(database); return execute_sql(path, "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE geometric_verifier_tasks;" "DROP TABLE geometric_verification_results;" "UPDATE metadata SET value=11 WHERE key='schema_version';" "COMMIT;PRAGMA foreign_keys=ON;"); @@ -166,7 +167,7 @@ static bool test_model_api(const char *path) { char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); Parents parents; CHECK(create_parents(database, &parents)); @@ -401,7 +402,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { "name='geometric_verification_results'", 0)); CHECK(lardon3d_project_db_open(v11_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); Parents parents; CHECK(create_parents(database, &parents)); unsigned char fingerprint[32] = {0x91}; @@ -413,7 +414,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { &migrated_result) == LARDON3D_PROJECT_DB_OK); uint64_t migrated_result_id = migrated_result.geometric_verification_result_id; lardon3d_project_db_close(database); - CHECK(query_integer(v11_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(v11_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(query_integer(v11_path, "SELECT count(*) FROM sqlite_master WHERE type='index' AND " "name='geometric_verification_results_parent_idx'", @@ -434,7 +435,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { "name='geometric_verification_results'", 0)); CHECK(lardon3d_project_db_open(failed_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); return true; } diff --git a/tests/test_geometric_verifier_core.cpp b/tests/test_geometric_verifier_core.cpp new file mode 100644 index 0000000..b9e2174 --- /dev/null +++ b/tests/test_geometric_verifier_core.cpp @@ -0,0 +1,807 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +} + +static int failures; + +struct Fixture { + char root[PATH_MAX]; + char database_path[PATH_MAX]; + Lardon3DAppState state; + Lardon3DProjectDbFeatureSet set_a; + Lardon3DProjectDbFeatureSet set_b; + Lardon3DProjectDbCandidatePair pair; + uint32_t parent_sequence; +}; + +static void to_hex(const unsigned char *bytes, size_t size, char *output) { + static const char digits[] = "0123456789abcdef"; + for (size_t index = 0; index < size; ++index) { + output[2 * index] = digits[bytes[index] >> 4U]; + output[2 * index + 1] = digits[bytes[index] & 15U]; + } + output[2 * size] = '\0'; +} + +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \ + #expression); \ + ++failures; \ + } \ + } while (0) + +static bool join_path(char output[PATH_MAX], const char *left, + const char *right) { + int length = std::snprintf(output, PATH_MAX, "%s/%s", left, right); + return length > 0 && length < PATH_MAX; +} + +static bool remove_tree(const char *path) { + struct stat information; + if (lstat(path, &information) != 0) + return errno == ENOENT; + if (!S_ISDIR(information.st_mode)) + return unlink(path) == 0; + DIR *directory = opendir(path); + if (!directory) + return false; + bool ok = true; + for (dirent *entry = readdir(directory); entry; entry = readdir(directory)) { + if (std::strcmp(entry->d_name, ".") == 0 || + std::strcmp(entry->d_name, "..") == 0) + continue; + char child[PATH_MAX]; + if (!join_path(child, path, entry->d_name) || !remove_tree(child)) + ok = false; + } + if (closedir(directory) != 0 || rmdir(path) != 0) + ok = false; + return ok; +} + +static bool execute_sql(const char *path, const char *sql) { + sqlite3 *connection = nullptr; + if (sqlite3_open(path, &connection) != SQLITE_OK) + return false; + bool ok = + sqlite3_exec(connection, sql, nullptr, nullptr, nullptr) == SQLITE_OK; + return sqlite3_close(connection) == SQLITE_OK && ok; +} + +static void image_asset_path(const unsigned char hash[32], char path[4096]) { + static const char digits[] = "0123456789abcdef"; + char hex[65]; + for (size_t index = 0; index < 32; ++index) { + hex[2 * index] = digits[hash[index] >> 4U]; + hex[2 * index + 1] = digits[hash[index] & 15U]; + } + hex[64] = '\0'; + (void)std::snprintf(path, 4096, "assets/images/%c%c/%s", hex[0], hex[1], hex); +} + +static bool register_image(Fixture *fixture, unsigned char seed, + Lardon3DProjectDbImage *image) { + unsigned char hash[32]; + std::memset(hash, seed, sizeof(hash)); + char path[4096]; + image_asset_path(hash, path); + Lardon3DProjectDbImageRegisterStatus status; + return lardon3d_project_db_register_image( + fixture->state.project_db, 1, hash, path, 1, "fixture.bin", + "/fixture.bin", 0, seed, &status, image) == LARDON3D_PROJECT_DB_OK; +} + +static bool publish_features(Fixture *fixture, + const Lardon3DProjectDbImage *image, + unsigned char salt, + Lardon3DProjectDbFeatureSet *set) { + constexpr uint32_t feature_count = LARDON3D_MATCH_FILE_MAX_MATCHES; + std::vector keypoints(feature_count); + std::vector descriptors( + static_cast(feature_count) * 32, salt); + for (uint32_t index = 0; index < feature_count; ++index) { + uint32_t source = salt == 4 ? (index * 3511U) % feature_count : index; + keypoints[index].x = 20.0F + static_cast(source % 128U) * 7.0F; + keypoints[index].y = 20.0F + static_cast(source / 128U) * 7.0F; + if (salt == 4) + keypoints[index].x -= 5.0F; + keypoints[index].size = 1.0F; + } + Lardon3DExtractedFeatures features = {}; + features.image_width = 1024; + features.image_height = 768; + features.feature_count = feature_count; + features.keypoints = keypoints.data(); + features.descriptors = descriptors.data(); + features.descriptor_bytes = descriptors.size(); + unsigned char fingerprint[32]; + std::memset(fingerprint, salt, sizeof(fingerprint)); + return lardon3d_feature_store_publish_v2( + &fixture->state, image->image_id, 0, "orb", 1, fingerprint, + LARDON3D_FEATURE_DESCRIPTOR_U8, 32, 0, &features, + set) == LARDON3D_FEATURE_STORE_OK; +} + +static bool fixture_create(Fixture *fixture) { + std::memset(fixture, 0, sizeof(*fixture)); + char root[] = "/tmp/lardon3d-geometric-core-XXXXXX"; + char *created = mkdtemp(root); + if (!created || + std::snprintf(fixture->root, sizeof(fixture->root), "%s", created) <= 0 || + !join_path(fixture->database_path, fixture->root, "project.db")) + return false; + lardon3d_app_state_init(&fixture->state); + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + if (lardon3d_project_db_open(fixture->database_path, + &fixture->state.project_db, + error) != LARDON3D_PROJECT_DB_OK) + return false; + fixture->state.project_loaded = true; + (void)std::snprintf(fixture->state.project_path, + sizeof(fixture->state.project_path), "%s", fixture->root); + Lardon3DProjectDbScanSet scanset; + Lardon3DProjectDbImage image_a, image_b; + return lardon3d_project_db_create_scanset(fixture->state.project_db, + "geometry", &scanset) == + LARDON3D_PROJECT_DB_OK && + scanset.scanset_id == 1 && register_image(fixture, 1, &image_a) && + register_image(fixture, 2, &image_b) && + publish_features(fixture, &image_a, 3, &fixture->set_a) && + publish_features(fixture, &image_b, 4, &fixture->set_b) && + lardon3d_project_db_create_candidate_pair( + fixture->state.project_db, image_a.image_id, image_b.image_id, 1, + &fixture->pair) == LARDON3D_PROJECT_DB_OK; +} + +static void fixture_destroy(Fixture *fixture) { + if (fixture->state.project_db) + lardon3d_project_db_close(fixture->state.project_db); + fixture->state.project_db = nullptr; + CHECK(remove_tree(fixture->root)); +} + +static bool sha256_file(const char *path, unsigned char output[32], + uint64_t *size) { + int descriptor = open(path, O_RDONLY | O_CLOEXEC); + if (descriptor < 0) + return false; + EVP_MD_CTX *context = EVP_MD_CTX_new(); + bool ok = context && EVP_DigestInit_ex(context, EVP_sha256(), nullptr) == 1; + unsigned char bytes[4096]; + uint64_t total = 0; + for (;;) { + ssize_t count = read(descriptor, bytes, sizeof(bytes)); + if (count < 0) { + ok = false; + break; + } + if (count == 0) + break; + total += static_cast(count); + if (EVP_DigestUpdate(context, bytes, static_cast(count)) != 1) + ok = false; + } + unsigned int digest_size = 0; + ok = ok && EVP_DigestFinal_ex(context, output, &digest_size) == 1 && + digest_size == 32; + EVP_MD_CTX_free(context); + if (close(descriptor) != 0) + ok = false; + *size = total; + return ok; +} + +static void put_u32(unsigned char bytes[4], uint32_t value) { + for (unsigned int index = 0; index < 4; ++index) + bytes[index] = static_cast(value >> (8U * index)); +} + +static bool create_parent(Fixture *fixture, uint32_t count, + uint64_t *parent_id) { + ++fixture->parent_sequence; + char relative[64]; + char full[PATH_MAX]; + int length = std::snprintf(relative, sizeof(relative), "matches-%u.bin", + fixture->parent_sequence); + if (length <= 0 || length >= static_cast(sizeof(relative)) || + !join_path(full, fixture->root, relative)) + return false; + std::vector entries(count); + for (uint32_t index = 0; index < count; ++index) { + entries[index].feature_index_a = index; + entries[index].feature_index_b = count == 0 ? 0 : (index * 7U) % count; + entries[index].distance = static_cast(index + 1U); + } + int descriptor = open(full, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (descriptor < 0) + return false; + bool ok = lardon3d_match_file_write( + descriptor, LARDON3D_FEATURE_DESCRIPTOR_U8, 32, + fixture->set_a.feature_set_id, fixture->set_b.feature_set_id, + entries.data(), count) == LARDON3D_MATCH_FILE_OK; + if (close(descriptor) != 0) + ok = false; + unsigned char sha256[32]; + uint64_t size = 0; + if (!ok || !sha256_file(full, sha256, &size)) + return false; + unsigned char fingerprint[32]; + std::memset(fingerprint, static_cast(fixture->parent_sequence), + sizeof(fingerprint)); + Lardon3DProjectDbMatchResult parent; + if (lardon3d_project_db_create_match_result( + fixture->state.project_db, fixture->pair.candidate_pair_id, + fixture->set_a.feature_set_id, fixture->set_b.feature_set_id, + "fixture", 1, fingerprint, LARDON3D_MATCH_RESULT_STATUS_MATCHED, + count, sha256, relative, size, fixture->parent_sequence, + &parent) != LARDON3D_PROJECT_DB_OK) + return false; + *parent_id = parent.match_result_id; + return true; +} + +static std::string mask_bits(uint32_t count, uint32_t inliers, + uint32_t stride = 1) { + std::string bits(count, '0'); + uint32_t position = 0; + for (uint32_t selected = 0; selected < inliers; ++selected) { + while (bits[position] == '1') + position = (position + 1U) % count; + bits[position] = '1'; + position = (position + stride) % count; + } + return bits; +} + +static void test_parameters_and_fingerprint() { + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + CHECK(lardon3d_geometric_verifier_parameters_valid(¶meters)); + unsigned char first[32], second[32], changed[32]; + lardon3d_geometric_verifier_fingerprint(¶meters, first); + lardon3d_geometric_verifier_fingerprint(¶meters, second); + CHECK(std::memcmp(first, second, sizeof(first)) == 0); + auto differs = [&](Lardon3DGeometricVerifierParameters changed_parameters) { + lardon3d_geometric_verifier_fingerprint(&changed_parameters, changed); + return std::memcmp(first, changed, sizeof(first)) != 0; + }; + parameters.threshold_pixels = 2.0; + CHECK(differs(parameters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.confidence = 0.99; + CHECK(differs(parameters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.max_iterations = 4000; + CHECK(differs(parameters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.min_inlier_count = 17; + CHECK(differs(parameters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.min_inlier_ratio = 0.21; + CHECK(differs(parameters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.seed_policy_version = 2; + CHECK(differs(parameters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.canonicalization_version = 2; + CHECK(differs(parameters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + unsigned char default_bytes[LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE]; + unsigned char changed_bytes[LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE]; + CHECK(lardon3d_geometric_verifier_fingerprint_bytes( + ¶meters, LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC, 1, default_bytes)); + CHECK(lardon3d_geometric_verifier_fingerprint_bytes( + ¶meters, LARDON3D_GEOMETRIC_ALGORITHM_USAC_DEFAULT, 1, + changed_bytes)); + CHECK(std::memcmp(default_bytes, changed_bytes, sizeof(default_bytes)) != 0); + CHECK(lardon3d_geometric_verifier_fingerprint_bytes( + ¶meters, LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC, 2, changed_bytes)); + CHECK(std::memcmp(default_bytes, changed_bytes, sizeof(default_bytes)) != 0); + CHECK(default_bytes[83] == 0); + parameters.min_inlier_ratio = 0.0; + CHECK(lardon3d_geometric_verifier_parameters_valid(¶meters)); + CHECK(lardon3d_geometric_verifier_fingerprint_bytes( + ¶meters, LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC, 1, default_bytes)); + parameters.min_inlier_ratio = -0.0; + CHECK(lardon3d_geometric_verifier_fingerprint_bytes( + ¶meters, LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC, 1, changed_bytes)); + CHECK(std::memcmp(default_bytes, changed_bytes, sizeof(default_bytes)) == 0); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.confidence = 1.0; + CHECK(!lardon3d_geometric_verifier_parameters_valid(¶meters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.threshold_pixels = std::numeric_limits::quiet_NaN(); + CHECK(!lardon3d_geometric_verifier_parameters_valid(¶meters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.max_iterations = std::numeric_limits::max(); + CHECK(!lardon3d_geometric_verifier_parameters_valid(¶meters)); + parameters = lardon3d_geometric_verifier_default_parameters(); + parameters.min_inlier_count = 8193; + CHECK(!lardon3d_geometric_verifier_parameters_valid(¶meters)); + + parameters = lardon3d_geometric_verifier_default_parameters(); + CHECK(lardon3d_geometric_verifier_fingerprint_bytes( + ¶meters, LARDON3D_GEOMETRIC_ALGORITHM_USAC_MAGSAC, 1, default_bytes)); + char encoded_hex[2 * LARDON3D_GEOMETRIC_VERIFIER_FINGERPRINT_SIZE + 1]; + char fingerprint_hex[65]; + to_hex(default_bytes, sizeof(default_bytes), encoded_hex); + to_hex(first, sizeof(first), fingerprint_hex); + CHECK(std::strcmp( + encoded_hex, + "4c3344475646503101000000010000000100000001000000000000000000f83f" + "2b8716d9cef7ef3f88130000100000009a9999999999c93f0100000001000000" + "0200020001050000000e00000001030300000000") == 0); + CHECK( + std::strcmp( + fingerprint_hex, + "ddb44bb070c62be66c405946e89cbb49c084f8f30a21d6f408dc239225b7bbd0") == + 0); +} + +static void test_seed() { + unsigned char match[32] = {}; + unsigned char fingerprint[32] = {}; + uint32_t first = lardon3d_geometric_verifier_seed(match, fingerprint); + CHECK(first == lardon3d_geometric_verifier_seed(match, fingerprint)); + match[31] = 1; + CHECK(first != lardon3d_geometric_verifier_seed(match, fingerprint)); + CHECK(lardon3d_geometric_verifier_seed(nullptr, fingerprint) == 0); + CHECK(first == 1803944746U); + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + unsigned char production_fingerprint[32]; + lardon3d_geometric_verifier_fingerprint(¶meters, production_fingerprint); + CHECK(lardon3d_geometric_verifier_seed(match, production_fingerprint) == + 1910542150U); +} + +static void test_canonicalization() { + const double source[9] = {-2.0, 1.0, 0.0, 0.5, -0.25, 0.0, 0.0, 0.0, 0.0}; + double positive[9], negative[9], scaled[9], half[9]; + for (size_t index = 0; index < 9; ++index) { + positive[index] = source[index]; + negative[index] = -source[index]; + scaled[index] = 2.0 * source[index]; + half[index] = 0.5 * source[index]; + } + CHECK(lardon3d_geometric_verifier_canonicalize(positive)); + CHECK(lardon3d_geometric_verifier_canonicalize(negative)); + CHECK(lardon3d_geometric_verifier_canonicalize(scaled)); + CHECK(lardon3d_geometric_verifier_canonicalize(half)); + CHECK(std::memcmp(positive, negative, sizeof(positive)) == 0); + for (size_t index = 0; index < 9; ++index) + CHECK(positive[index] == scaled[index]); + for (size_t index = 0; index < 9; ++index) + CHECK(positive[index] == half[index]); + CHECK(positive[0] > 0.0); + double zero[9] = {}; + CHECK(!lardon3d_geometric_verifier_canonicalize(zero)); + double invalid[9] = {std::numeric_limits::infinity()}; + CHECK(!lardon3d_geometric_verifier_canonicalize(invalid)); + invalid[0] = -std::numeric_limits::infinity(); + CHECK(!lardon3d_geometric_verifier_canonicalize(invalid)); + invalid[0] = std::numeric_limits::quiet_NaN(); + CHECK(!lardon3d_geometric_verifier_canonicalize(invalid)); + double tie[9] = {-1.0, 1.0}; + CHECK(lardon3d_geometric_verifier_canonicalize(tie)); + CHECK(tie[0] > 0.0 && tie[1] < 0.0); + double tiny[9] = {std::numeric_limits::denorm_min()}; + CHECK(lardon3d_geometric_verifier_canonicalize(tiny)); + CHECK(tiny[0] == 1.0); +} + +static bool +mask_matches(const Lardon3DProjectDbGeometricVerificationResult &result, + const std::string &bits) { + if (result.inlier_mask_size != (bits.size() + 7U) / 8U) + return false; + for (size_t index = 0; index < bits.size(); ++index) { + bool stored = (result.inlier_mask[index / 8U] & (1U << (index % 8U))) != 0; + if (stored != (bits[index] == '1')) + return false; + } + return true; +} + +static bool +run_controlled(Fixture *fixture, uint64_t parent_id, const std::string &bits, + const Lardon3DGeometricVerifierParameters *parameters, + Lardon3DProjectDbGeometricVerificationResult *result, + bool *reused) { + if (setenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR", "controlled", 1) != 0 || + setenv("LARDON3D_TEST_GEOMETRIC_MASK", bits.c_str(), 1) != 0) + return false; + Lardon3DGeometricVerifierResult status = + lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, parent_id, parameters, + result, reused); + (void)unsetenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR"); + (void)unsetenv("LARDON3D_TEST_GEOMETRIC_MASK"); + return status == LARDON3D_GEOMETRIC_VERIFIER_OK; +} + +static void test_e2e_and_reuse(Fixture *fixture) { + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + uint64_t verified_parent = 0; + CHECK(create_parent(fixture, 32, &verified_parent)); + std::string verified_bits = mask_bits(32, 20, 7); + Lardon3DProjectDbGeometricVerificationResult verified; + bool reused = false; + lardon3d_geometric_verifier_test_reset_estimator_calls(); + CHECK(run_controlled(fixture, verified_parent, verified_bits, ¶meters, + &verified, &reused)); + CHECK(!reused && lardon3d_geometric_verifier_test_estimator_calls() == 1); + CHECK(verified.status == LARDON3D_GEOMETRIC_VERIFIED && verified.has_model && + verified.inlier_count == 20 && mask_matches(verified, verified_bits)); + double norm = 0.0; + for (double value : verified.model) { + CHECK(std::isfinite(value)); + norm = std::hypot(norm, value); + } + CHECK(std::fabs(norm - 1.0) < 1e-15); + Lardon3DProjectDbGeometricVerificationResult reused_verified; + CHECK(run_controlled(fixture, verified_parent, verified_bits, ¶meters, + &reused_verified, &reused)); + CHECK(reused && lardon3d_geometric_verifier_test_estimator_calls() == 1 && + reused_verified.geometric_verification_result_id == + verified.geometric_verification_result_id); + + uint64_t rejected_parent = 0; + CHECK(create_parent(fixture, 32, &rejected_parent)); + std::string rejected_bits = mask_bits(32, 15, 5); + Lardon3DProjectDbGeometricVerificationResult rejected; + CHECK(run_controlled(fixture, rejected_parent, rejected_bits, ¶meters, + &rejected, &reused)); + CHECK(!reused && rejected.status == LARDON3D_GEOMETRIC_REJECTED && + !rejected.has_model && rejected.inlier_count == 15 && + mask_matches(rejected, rejected_bits)); + uint32_t calls_after_rejected = + lardon3d_geometric_verifier_test_estimator_calls(); + CHECK(run_controlled(fixture, rejected_parent, rejected_bits, ¶meters, + &rejected, &reused)); + CHECK(reused && lardon3d_geometric_verifier_test_estimator_calls() == + calls_after_rejected); + + parameters.threshold_pixels = 2.0; + Lardon3DProjectDbGeometricVerificationResult different; + CHECK(run_controlled(fixture, verified_parent, verified_bits, ¶meters, + &different, &reused)); + CHECK(!reused && different.geometric_verification_result_id != + verified.geometric_verification_result_id); +} + +static void test_scientific_boundaries(Fixture *fixture) { + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + uint64_t insufficient_parent = 0; + CHECK(create_parent(fixture, 6, &insufficient_parent)); + Lardon3DProjectDbGeometricVerificationResult insufficient; + bool reused = false; + lardon3d_geometric_verifier_test_reset_estimator_calls(); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, insufficient_parent, + ¶meters, &insufficient, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_OK); + CHECK(!reused && insufficient.status == LARDON3D_GEOMETRIC_REJECTED && + insufficient.inlier_count == 0 && !insufficient.has_model && + insufficient.inlier_mask_size == 1 && + insufficient.inlier_mask[0] == 0 && + lardon3d_geometric_verifier_test_estimator_calls() == 0); + + uint64_t empty_parent = 0; + CHECK(create_parent(fixture, 16, &empty_parent)); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR", "empty", 1) == 0); + Lardon3DProjectDbGeometricVerificationResult empty; + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, empty_parent, ¶meters, + &empty, &reused) == LARDON3D_GEOMETRIC_VERIFIER_OK); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR") == 0); + CHECK(empty.status == LARDON3D_GEOMETRIC_REJECTED && + empty.inlier_count == 0 && !empty.has_model); + + for (uint32_t inliers : {15U, 16U, 17U}) { + uint64_t parent = 0; + CHECK(create_parent(fixture, 80, &parent)); + std::string bits = mask_bits(80, inliers, 9); + Lardon3DProjectDbGeometricVerificationResult result; + CHECK(run_controlled(fixture, parent, bits, ¶meters, &result, &reused)); + CHECK(result.inlier_count == inliers && mask_matches(result, bits)); + CHECK(result.status == (inliers >= 16 ? LARDON3D_GEOMETRIC_VERIFIED + : LARDON3D_GEOMETRIC_REJECTED)); + } +} + +static void test_mask_boundaries(Fixture *fixture) { + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + parameters.min_inlier_count = 1; + parameters.min_inlier_ratio = 0.0; + for (uint32_t count : {1U, 2U}) { + uint64_t parent = 0; + CHECK(create_parent(fixture, count, &parent)); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, parent, ¶meters, + &result, &reused) == LARDON3D_GEOMETRIC_VERIFIER_OK); + CHECK(result.status == LARDON3D_GEOMETRIC_REJECTED && + result.inlier_count == 0 && result.inlier_mask_size == 1 && + result.inlier_mask[0] == 0); + } + for (uint32_t count : {7U, 8U, 9U, 63U, 64U, 65U, 8191U, 8192U}) { + uint64_t parent = 0; + CHECK(create_parent(fixture, count, &parent)); + std::string bits = mask_bits(count, (count + 1U) / 2U, 11); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + CHECK(run_controlled(fixture, parent, bits, ¶meters, &result, &reused)); + CHECK(!reused && result.status == LARDON3D_GEOMETRIC_VERIFIED && + result.inlier_count == (count + 1U) / 2U && + mask_matches(result, bits)); + unsigned int remainder = count % 8U; + if (remainder != 0) + CHECK((result.inlier_mask[result.inlier_mask_size - 1] & + static_cast(0xffU << remainder)) == 0); + } +} + +static void test_failures(Fixture *fixture) { + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + for (const char *behavior : + {"error", "malformed-mask", "malformed-model", "nan-model"}) { + uint64_t parent = 0; + CHECK(create_parent(fixture, 32, &parent)); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR", behavior, 1) == 0); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, parent, ¶meters, + &result, &reused) == LARDON3D_GEOMETRIC_VERIFIER_ESTIMATOR_ERROR); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR") == 0); + unsigned char fingerprint[32]; + lardon3d_geometric_verifier_fingerprint(¶meters, fingerprint); + CHECK(lardon3d_project_db_find_geometric_verification_result( + fixture->state.project_db, parent, + LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, 1, fingerprint, + &result) == LARDON3D_PROJECT_DB_NOT_FOUND); + } + + uint64_t publication_parent = 0; + CHECK(create_parent(fixture, 32, &publication_parent)); + std::string bits = mask_bits(32, 20, 3); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_PUBLICATION_FAILURE", "1", 1) == 0); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR", "controlled", 1) == 0); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_MASK", bits.c_str(), 1) == 0); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, publication_parent, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_DATABASE_ERROR); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_PUBLICATION_FAILURE") == 0); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR") == 0); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_MASK") == 0); + unsigned char fingerprint[32]; + lardon3d_geometric_verifier_fingerprint(¶meters, fingerprint); + CHECK(lardon3d_project_db_find_geometric_verification_result( + fixture->state.project_db, publication_parent, + LARDON3D_GEOMETRIC_VERIFIER_FUNDAMENTAL, 1, fingerprint, + &result) == LARDON3D_PROJECT_DB_NOT_FOUND); + CHECK(run_controlled(fixture, publication_parent, bits, ¶meters, &result, + &reused)); + CHECK(!reused && result.status == LARDON3D_GEOMETRIC_VERIFIED); + + uint64_t allocation_parent = 0; + CHECK(create_parent(fixture, 32, &allocation_parent)); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR", "bad-alloc", 1) == 0); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, allocation_parent, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_OUT_OF_MEMORY); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_ESTIMATOR") == 0); +} + +static void test_parent_and_asset_failures(Fixture *fixture) { + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, UINT64_C(999999), + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_NOT_FOUND); + + unsigned char fingerprint[32] = {}; + Lardon3DProjectDbMatchResult no_match; + CHECK(lardon3d_project_db_create_match_result( + fixture->state.project_db, fixture->pair.candidate_pair_id, + fixture->set_a.feature_set_id, fixture->set_b.feature_set_id, + "no-match", 1, fingerprint, LARDON3D_MATCH_RESULT_STATUS_NO_MATCH, + 0, nullptr, nullptr, 0, 1, &no_match) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, no_match.match_result_id, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_NOT_FOUND); + + uint64_t missing_asset_parent = 0; + CHECK(create_parent(fixture, 32, &missing_asset_parent)); + Lardon3DProjectDbMatchResult parent; + CHECK(lardon3d_project_db_load_match_result(fixture->state.project_db, + missing_asset_parent, &parent) == + LARDON3D_PROJECT_DB_OK); + char full[PATH_MAX]; + CHECK(join_path(full, fixture->root, parent.match_asset_path)); + CHECK(unlink(full) == 0); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, missing_asset_parent, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_CORRUPT); + + uint64_t truncated_parent = 0; + CHECK(create_parent(fixture, 32, &truncated_parent)); + CHECK(lardon3d_project_db_load_match_result(fixture->state.project_db, + truncated_parent, &parent) == + LARDON3D_PROJECT_DB_OK); + CHECK(join_path(full, fixture->root, parent.match_asset_path)); + CHECK(truncate(full, LARDON3D_MATCH_FILE_HEADER_SIZE) == 0); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, truncated_parent, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_CORRUPT); + + uint64_t index_parent = 0; + CHECK(create_parent(fixture, 32, &index_parent)); + CHECK(lardon3d_project_db_load_match_result(fixture->state.project_db, + index_parent, &parent) == + LARDON3D_PROJECT_DB_OK); + CHECK(join_path(full, fixture->root, parent.match_asset_path)); + int descriptor = open(full, O_WRONLY | O_CLOEXEC); + CHECK(descriptor >= 0); + unsigned char invalid_index[4]; + put_u32(invalid_index, LARDON3D_MATCH_FILE_MAX_MATCHES); + CHECK(pwrite(descriptor, invalid_index, sizeof(invalid_index), + LARDON3D_MATCH_FILE_HEADER_SIZE) == + static_cast(sizeof(invalid_index))); + CHECK(close(descriptor) == 0); + unsigned char corrupt_sha[32]; + uint64_t corrupt_size = 0; + CHECK(sha256_file(full, corrupt_sha, &corrupt_size)); + char corrupt_relative[LARDON3D_PROJECT_DB_PATH_CAPACITY]; + (void)std::snprintf(corrupt_relative, sizeof(corrupt_relative), "%s", + parent.match_asset_path); + uint32_t corrupt_count = parent.match_count; + Lardon3DProjectDbMatchResult repaired; + CHECK(lardon3d_project_db_repair_match_result( + fixture->state.project_db, index_parent, + LARDON3D_MATCH_RESULT_STATUS_MATCHED, corrupt_count, corrupt_sha, + corrupt_relative, corrupt_size, + &repaired) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, index_parent, ¶meters, + &result, &reused) == LARDON3D_GEOMETRIC_VERIFIER_CORRUPT); +} + +static void test_real_estimator_and_reopen(Fixture *fixture) { + uint64_t parent = 0; + CHECK(create_parent(fixture, 8192, &parent)); + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, parent, ¶meters, + &result, &reused) == LARDON3D_GEOMETRIC_VERIFIER_OK); + CHECK(!reused && result.status == LARDON3D_GEOMETRIC_VERIFIED && + result.has_model && + result.inlier_count >= parameters.min_inlier_count && + result.inlier_mask_size == 1024); + Lardon3DProjectDbGeometricVerificationResult before_reopen = result; + uint64_t result_id = result.geometric_verification_result_id; + lardon3d_project_db_close(fixture->state.project_db); + fixture->state.project_db = nullptr; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(fixture->database_path, + &fixture->state.project_db, + error) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, parent, ¶meters, + &result, &reused) == LARDON3D_GEOMETRIC_VERIFIER_OK); + CHECK(reused && result.geometric_verification_result_id == result_id && + result.status == before_reopen.status && + result.inlier_count == before_reopen.inlier_count && + result.inlier_mask_size == before_reopen.inlier_mask_size && + std::memcmp(result.inlier_mask, before_reopen.inlier_mask, + before_reopen.inlier_mask_size) == 0 && + std::memcmp(result.model, before_reopen.model, sizeof(result.model)) == + 0); +} + +static void test_feature_failures(Fixture *fixture) { + Lardon3DGeometricVerifierParameters parameters = + lardon3d_geometric_verifier_default_parameters(); + Lardon3DProjectDbGeometricVerificationResult result; + bool reused = false; + + uint64_t ownership_parent = 0; + CHECK(create_parent(fixture, 32, &ownership_parent)); + char sql[256]; + int length = std::snprintf( + sql, sizeof(sql), + "UPDATE match_results SET feature_set_id_a=%llu WHERE " + "match_result_id=%llu", + static_cast(fixture->set_b.feature_set_id), + static_cast(ownership_parent)); + CHECK(length > 0 && length < static_cast(sizeof(sql)) && + execute_sql(fixture->database_path, sql)); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, ownership_parent, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_CORRUPT); + + uint64_t missing_asset_parent = 0; + CHECK(create_parent(fixture, 32, &missing_asset_parent)); + char feature_path[PATH_MAX]; + CHECK(join_path(feature_path, fixture->root, fixture->set_a.asset.path)); + CHECK(unlink(feature_path) == 0); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, missing_asset_parent, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_CORRUPT); + + uint64_t missing_set_parent = 0; + CHECK(create_parent(fixture, 32, &missing_set_parent)); + length = std::snprintf( + sql, sizeof(sql), + "PRAGMA foreign_keys=OFF;DELETE FROM feature_sets " + "WHERE feature_set_id=%llu;PRAGMA foreign_keys=ON", + static_cast(fixture->set_a.feature_set_id)); + CHECK(length > 0 && length < static_cast(sizeof(sql)) && + execute_sql(fixture->database_path, sql)); + CHECK(lardon3d_geometric_verifier_verify_and_publish( + fixture->root, fixture->state.project_db, missing_set_parent, + ¶meters, &result, + &reused) == LARDON3D_GEOMETRIC_VERIFIER_NOT_FOUND); +} + +int main() { + test_parameters_and_fingerprint(); + test_seed(); + test_canonicalization(); + Fixture fixture; + CHECK(fixture_create(&fixture)); + if (fixture.state.project_db) { + test_e2e_and_reuse(&fixture); + test_scientific_boundaries(&fixture); + test_mask_boundaries(&fixture); + test_failures(&fixture); + test_parent_and_asset_failures(&fixture); + test_real_estimator_and_reopen(&fixture); + test_feature_failures(&fixture); + } + fixture_destroy(&fixture); + if (failures == 0) + std::puts("geometric verifier core unit tests: PASS"); + return failures == 0 ? 0 : 1; +} diff --git a/tests/test_geometric_verifier_task.c b/tests/test_geometric_verifier_task.c new file mode 100644 index 0000000..a09bdf5 --- /dev/null +++ b/tests/test_geometric_verifier_task.c @@ -0,0 +1,358 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + (void)fprintf(stderr, "Échec ligne %d : %s\n", __LINE__, #condition); \ + return false; \ + } \ + } while (0) + +static int fixture_parent_count(void) { + const char *value = getenv("LARDON3D_TEST_GEOMETRIC_PARENT_COUNT"); + if (!value || !value[0]) { + return 12; + } + char *end = NULL; + long parsed = strtol(value, &end, 10); + return end && *end == '\0' && parsed >= 12 && parsed <= 10000 ? (int)parsed + : 12; +} + +static bool remove_tree(const char *path) { + struct stat information; + if (lstat(path, &information) != 0) { + return errno == ENOENT; + } + if (!S_ISDIR(information.st_mode)) { + return unlink(path) == 0; + } + DIR *directory = opendir(path); + if (!directory) { + return false; + } + bool ok = true; + for (struct dirent *entry = readdir(directory); entry; + entry = readdir(directory)) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + char child[4096]; + int written = snprintf(child, sizeof(child), "%s/%s", path, entry->d_name); + if (written <= 0 || (size_t)written >= sizeof(child) || + !remove_tree(child)) { + ok = false; + } + } + return closedir(directory) == 0 && rmdir(path) == 0 && ok; +} + +static Lardon3DResourcePolicy policy(void) { + return (Lardon3DResourcePolicy){ + .system_memory_reserve_bytes = 4ULL * 1024 * 1024 * 1024, + .emergency_memory_floor_bytes = 2ULL * 1024 * 1024 * 1024, + .system_cpu_reserve = 4, + .maximum_cpu_load_ratio = 1.0, + .maximum_cpu_pressure_avg10 = 100.0, + .maximum_memory_pressure_avg10 = 100.0, + .maximum_io_pressure_avg10 = 100.0, + .io_slot_capacity = 1, + .gpu_slot_capacity = 1, + }; +} + +static bool wait_state(Lardon3DTaskQueue *queue, uint64_t id, + Lardon3DTaskState wanted, + Lardon3DTaskSnapshot *snapshot) { + for (size_t attempt = 0; attempt < 2000000; ++attempt) { + if (lardon3d_task_queue_get(queue, id, snapshot) && + snapshot->state == wanted) { + return true; + } + sched_yield(); + } + return false; +} + +static bool wait_durable(Lardon3DProjectDb *database, uint64_t id, + Lardon3DTaskState wanted) { + for (size_t attempt = 0; attempt < 2000000; ++attempt) { + Lardon3DProjectDbTask task; + if (lardon3d_project_db_load_task(database, id, &task) == + LARDON3D_PROJECT_DB_OK && + task.saved_state == wanted) { + return true; + } + sched_yield(); + } + return false; +} + +static bool exec_sql(const char *path, const char *sql) { + sqlite3 *db = NULL; + bool ok = + sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK && + sqlite3_exec(db, sql, NULL, NULL, NULL) == SQLITE_OK; + return sqlite3_close(db) == SQLITE_OK && ok; +} + +static bool query_integer(const char *path, const char *sql, + sqlite3_int64 expected) { + sqlite3 *db = NULL; + sqlite3_stmt *statement = NULL; + bool ok = + sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK && + sqlite3_prepare_v2(db, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW && + sqlite3_column_int64(statement, 0) == expected; + if (statement) { + (void)sqlite3_finalize(statement); + } + return sqlite3_close(db) == SQLITE_OK && ok; +} + +static bool seed_reusable_parents(const char *path, + const unsigned char fingerprint[32]) { + sqlite3 *db = NULL; + CHECK(sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK); + static const char setup[] = + "PRAGMA foreign_keys=ON;BEGIN IMMEDIATE;" + "INSERT INTO scansets(name,created_at,updated_at) VALUES('task',1,1);" + "INSERT INTO image_assets(sha256,path,size_bytes,state,created_at) " + "VALUES(zeroblob(32),'assets/images/00/a',1,1,1)," + "(randomblob(32),'assets/images/00/b',1,1,1);" + "INSERT INTO " + "images(scanset_id,asset_id,original_name,source_path,imported_at) " + "VALUES(1,1,'a','a',1),(1,2,'b','b',1);" + "INSERT INTO " + "feature_assets(sha256,path,size_bytes,durability,created_at) " + "VALUES(randomblob(32),'assets/features/a',1,0,1)," + "(randomblob(32),'assets/features/b',1,0,1);" + "INSERT INTO " + "feature_sets(image_id,feature_asset_id,extractor_kind,extractor_version," + "parameter_fingerprint,source_image_sha256,feature_count,descriptor_type," + "descriptor_dimension,created_at) VALUES" + "(1,1,'orb',1,zeroblob(32),zeroblob(32),16,1,32,1)," + "(2,2,'orb',1,zeroblob(32),zeroblob(32),16,1,32,1);" + "INSERT INTO candidate_pairs(image_id_a,image_id_b,created_at) " + "VALUES(1,2,1);" + "COMMIT;"; + CHECK(sqlite3_exec(db, setup, NULL, NULL, NULL) == SQLITE_OK); + sqlite3_stmt *parent = NULL; + sqlite3_stmt *result = NULL; + CHECK(sqlite3_prepare_v2( + db, + "INSERT INTO " + "match_results(candidate_pair_id,feature_set_id_a,feature_set_id_b," + "matcher_kind,matcher_version,parameter_fingerprint,result_status," + "match_count," + "match_asset_sha256,match_asset_path,match_asset_size_bytes," + "created_at) " + "VALUES(1,1,2,?1,1,?2,1,16,?3,?4,128,1)", + -1, &parent, NULL) == SQLITE_OK); + CHECK(sqlite3_prepare_v2( + db, + "INSERT INTO " + "geometric_verification_results(match_result_id,verifier_kind," + "verifier_version,parameter_fingerprint,status,inlier_count,inlier_" + "mask,created_at) " + "VALUES(?1,1,1,?2,1,0,zeroblob(2),1)", + -1, &result, NULL) == SQLITE_OK); + for (int index = 0; index < fixture_parent_count(); ++index) { + char kind[32]; + char asset[64]; + unsigned char identity[32]; + unsigned char match_hash[32]; + memset(identity, index + 1, sizeof(identity)); + memset(match_hash, index + 33, sizeof(match_hash)); + (void)snprintf(kind, sizeof(kind), "fixture-%d", index); + (void)snprintf(asset, sizeof(asset), "assets/matches/%d", index); + sqlite3_bind_text(parent, 1, kind, -1, SQLITE_TRANSIENT); + sqlite3_bind_blob(parent, 2, identity, 32, SQLITE_TRANSIENT); + sqlite3_bind_blob(parent, 3, match_hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(parent, 4, asset, -1, SQLITE_TRANSIENT); + CHECK(sqlite3_step(parent) == SQLITE_DONE); + CHECK(sqlite3_reset(parent) == SQLITE_OK); + sqlite3_bind_int64(result, 1, sqlite3_last_insert_rowid(db)); + sqlite3_bind_blob(result, 2, fingerprint, 32, SQLITE_TRANSIENT); + CHECK(sqlite3_step(result) == SQLITE_DONE); + CHECK(sqlite3_reset(result) == SQLITE_OK); + } + CHECK(sqlite3_finalize(parent) == SQLITE_OK); + CHECK(sqlite3_finalize(result) == SQLITE_OK); + return sqlite3_close(db) == SQLITE_OK; +} + +static bool add_reusable_results(const char *path, + const unsigned char fingerprint[32]) { + sqlite3 *db = NULL; + sqlite3_stmt *statement = NULL; + CHECK(sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK); + CHECK( + sqlite3_prepare_v2( + db, + "INSERT INTO " + "geometric_verification_results(match_result_id,verifier_kind," + "verifier_version,parameter_fingerprint,status,inlier_count,inlier_" + "mask,created_at) " + "SELECT match_result_id,1,1,?1,1,0,zeroblob(2),2 FROM match_results", + -1, &statement, NULL) == SQLITE_OK); + sqlite3_bind_blob(statement, 1, fingerprint, 32, SQLITE_TRANSIENT); + int code = sqlite3_step(statement); + if (code != SQLITE_DONE) { + (void)fprintf(stderr, "Insertion reuse: %s\n", sqlite3_errmsg(db)); + } + CHECK(code == SQLITE_DONE); + CHECK(sqlite3_finalize(statement) == SQLITE_OK); + return sqlite3_close(db) == SQLITE_OK; +} + +static bool run_task_test(void) { + char root[] = "/tmp/lardon3d-geometric-task-XXXXXX"; + CHECK(mkdtemp(root) != NULL); + char internal[4096]; + char checkpoints[4096]; + char database_path[4096]; + (void)snprintf(internal, sizeof(internal), "%s/.lardon3d", root); + (void)snprintf(checkpoints, sizeof(checkpoints), "%s/checkpoints", internal); + (void)snprintf(database_path, sizeof(database_path), "%s/project.sqlite3", + internal); + CHECK(mkdir(internal, 0700) == 0 && mkdir(checkpoints, 0700) == 0); + + Lardon3DAppState state; + lardon3d_app_state_init(&state); + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; + CHECK(lardon3d_project_db_open(database_path, &state.project_db, error) == + LARDON3D_PROJECT_DB_OK); + state.project_loaded = true; + (void)snprintf(state.project_path, sizeof(state.project_path), "%s", root); + state.hardware_profile = (Lardon3DHardwareProfile){ + .logical_cpu_count = 16, + .page_size_bytes = 4096, + .memory_total_bytes = 16ULL * 1024 * 1024 * 1024, + .cpu_architecture = "test", + }; + Lardon3DResourcePolicy resource_policy = policy(); + state.resource_governor = lardon3d_resource_governor_create( + &state.hardware_profile, &resource_policy); + state.task_queue = lardon3d_task_queue_create(state.resource_governor, 16); + CHECK(state.resource_governor && state.task_queue); + + Lardon3DGeometricVerifierTaskConfiguration configuration = { + .verifier = lardon3d_geometric_verifier_default_parameters(), + }; + unsigned char fingerprint[32]; + lardon3d_geometric_verifier_fingerprint(&configuration.verifier, fingerprint); + CHECK(seed_reusable_parents(database_path, fingerprint)); + + const Lardon3DTaskKindRegistry *registry = + lardon3d_task_kind_registry_production(); + const Lardon3DTaskKindDescriptor *descriptor = NULL; + CHECK(lardon3d_task_kind_registry_lookup( + registry, LARDON3D_GEOMETRIC_VERIFIER_TASK_KIND, 1, &descriptor) == + LARDON3D_TASK_KIND_OK); + uint64_t task_id = 0; + CHECK(lardon3d_project_enqueue_geometric_verifier_task(&state, &configuration, + &task_id)); + Lardon3DTaskSnapshot snapshot; + CHECK(wait_durable(state.project_db, task_id, TASK_COMPLETED)); + Lardon3DProjectDbGeometricVerifierTask durable; + CHECK(lardon3d_project_db_load_geometric_verifier_task( + state.project_db, task_id, &durable) == LARDON3D_PROJECT_DB_OK && + durable.after_match_result_id == (uint64_t)fixture_parent_count() && + memcmp(durable.parameter_fingerprint, fingerprint, 32) == 0); + + configuration.verifier.threshold_pixels = 1.6; + lardon3d_geometric_verifier_fingerprint(&configuration.verifier, fingerprint); + CHECK(add_reusable_results(database_path, fingerprint)); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_PAUSE_AFTER_PUBLICATION", "1", 1) == 0); + CHECK(lardon3d_project_enqueue_geometric_verifier_task(&state, &configuration, + &task_id)); + CHECK(wait_state(state.task_queue, task_id, TASK_PAUSED, &snapshot)); + CHECK(lardon3d_project_db_load_geometric_verifier_task( + state.project_db, task_id, &durable) == LARDON3D_PROJECT_DB_OK && + durable.after_match_result_id == 0); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_SKIP_FINISHED_CHECKPOINT", "1", 1) == + 0); + lardon3d_task_queue_destroy(state.task_queue); + state.task_queue = NULL; + lardon3d_project_db_close(state.project_db); + state.project_db = NULL; + CHECK(lardon3d_project_db_open(database_path, &state.project_db, error) == + LARDON3D_PROJECT_DB_OK); + state.task_queue = lardon3d_task_queue_create(state.resource_governor, 16); + CHECK(state.task_queue != NULL); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_PAUSE_AFTER_PUBLICATION") == 0); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_SKIP_FINISHED_CHECKPOINT") == 0); + Lardon3DProjectRecoverySummary recovery; + Lardon3DProjectDbResult recovery_result = + lardon3d_project_resume_recoverable_tasks( + &state, lardon3d_task_kind_registry_production(), &recovery); + if (recovery_result != LARDON3D_PROJECT_DB_OK || recovery.resumed != 1) { + (void)fprintf( + stderr, + "Recovery result=%d inspected=%zu resumed=%zu skipped=%zu failed=%zu\n", + recovery_result, recovery.inspected, recovery.resumed, recovery.skipped, + recovery.failed); + } + CHECK(recovery_result == LARDON3D_PROJECT_DB_OK && recovery.resumed == 1); + CHECK(wait_durable(state.project_db, task_id, TASK_COMPLETED)); + CHECK(lardon3d_project_db_load_geometric_verifier_task( + state.project_db, task_id, &durable) == LARDON3D_PROJECT_DB_OK && + durable.after_match_result_id == (uint64_t)fixture_parent_count()); + + configuration.verifier.threshold_pixels = 1.7; + lardon3d_geometric_verifier_fingerprint(&configuration.verifier, fingerprint); + CHECK(add_reusable_results(database_path, fingerprint)); + CHECK(setenv("LARDON3D_TEST_GEOMETRIC_PAUSE_AFTER_PUBLICATION", "1", 1) == 0); + CHECK(lardon3d_project_enqueue_geometric_verifier_task(&state, &configuration, + &task_id)); + CHECK(wait_state(state.task_queue, task_id, TASK_PAUSED, &snapshot)); + CHECK(lardon3d_task_queue_cancel(state.task_queue, task_id)); + CHECK(wait_state(state.task_queue, task_id, TASK_CANCELLED, &snapshot)); + CHECK(unsetenv("LARDON3D_TEST_GEOMETRIC_PAUSE_AFTER_PUBLICATION") == 0); + + lardon3d_task_queue_destroy(state.task_queue); + lardon3d_resource_governor_destroy(state.resource_governor); + lardon3d_project_db_close(state.project_db); + CHECK(exec_sql( + database_path, + "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE geometric_verifier_tasks;" + "UPDATE metadata SET value=12 WHERE key='schema_version';COMMIT;")); + CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V13", "1", 1) == 0); + Lardon3DProjectDb *database = NULL; + CHECK(lardon3d_project_db_open(database_path, &database, error) == + LARDON3D_PROJECT_DB_IO_ERROR); + CHECK(unsetenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V13") == 0); + CHECK(query_integer(database_path, + "SELECT value FROM metadata WHERE key='schema_version'", + 12)); + CHECK( + query_integer(database_path, + "SELECT count(*) FROM sqlite_master WHERE type='table' AND " + "name='geometric_verifier_tasks'", + 0)); + CHECK(lardon3d_project_db_open(database_path, &database, error) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_schema_version(database) == 13); + lardon3d_project_db_close(database); + CHECK(remove_tree(root)); + return true; +} + +int main(void) { return run_task_test() ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/tests/test_match_result.c b/tests/test_match_result.c index a9d8e16..3fafeae 100644 --- a/tests/test_match_result.c +++ b/tests/test_match_result.c @@ -48,6 +48,7 @@ static bool create_v9_database(const char *path) { if (sqlite3_open(path, &connection) != SQLITE_OK) return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE geometric_verifier_tasks;" "DROP TABLE geometric_verification_results;" "DROP TABLE matcher_tasks;" "DROP TABLE match_results;" @@ -94,7 +95,7 @@ static bool run_test(void) { char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(database && lardon3d_project_db_schema_version(database) == 12); + CHECK(database && lardon3d_project_db_schema_version(database) == 13); Lardon3DProjectDbScanSet scanset; CHECK(lardon3d_project_db_create_scanset(database, "Match-test", &scanset) == @@ -453,7 +454,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) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); /* Verify persistence: load previously created results */ CHECK(lardon3d_project_db_load_match_result(database, first_id, &loaded) == @@ -493,10 +494,10 @@ static bool run_test(void) { CHECK(create_v9_database(v9_path)); CHECK(query_integer(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) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL; - CHECK(query_integer(v9_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(v9_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(query_integer(v9_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " "name='match_results'", 1)); @@ -515,7 +516,7 @@ static bool run_test(void) { "SELECT count(*) FROM sqlite_master WHERE type='table' AND " "name='matcher_tasks'", 0)); CHECK(lardon3d_project_db_open(failed_v10_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 12); + lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL; diff --git a/tests/test_matcher_task.c b/tests/test_matcher_task.c index f46626d..92e8c35 100644 --- a/tests/test_matcher_task.c +++ b/tests/test_matcher_task.c @@ -89,6 +89,7 @@ static bool downgrade_project_to_historical_v10(const char *database_path) { } static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE geometric_verifier_tasks;" "DROP TABLE geometric_verification_results;" "DROP TABLE matcher_tasks;" "UPDATE metadata SET value=10 WHERE key='schema_version';" @@ -351,7 +352,7 @@ static bool run_test(void) { "name='matcher_tasks'", 0)); CHECK(reopen_runtime(&fixture)); - CHECK(lardon3d_project_db_schema_version(fixture.state.project_db) == 12); + CHECK(lardon3d_project_db_schema_version(fixture.state.project_db) == 13); CHECK(query_integer(database_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " "name='matcher_tasks'", diff --git a/tests/test_project_db.c b/tests/test_project_db.c index 49bac46..e4a7bf3 100644 --- a/tests/test_project_db.c +++ b/tests/test_project_db.c @@ -89,7 +89,7 @@ static bool create_future_database(const char *path) { } bool ok = sqlite3_exec(connection, "CREATE TABLE metadata(key TEXT PRIMARY KEY,value INTEGER NOT NULL);" - "INSERT INTO metadata VALUES('schema_version',13);", + "INSERT INTO metadata VALUES('schema_version',14);", NULL, NULL, NULL) == SQLITE_OK; return sqlite3_close(connection) == SQLITE_OK && ok; } @@ -103,6 +103,7 @@ static bool create_v7_database(const char *path) { if (sqlite3_open(path, &connection) != SQLITE_OK) return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE geometric_verifier_tasks;" "DROP TABLE geometric_verification_results;" "DROP TABLE matcher_tasks;" "DROP TABLE match_results;" @@ -122,6 +123,7 @@ static bool create_v6_database(const char *path) { if (sqlite3_open(path, &connection) != SQLITE_OK) return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE geometric_verifier_tasks;" "DROP TABLE geometric_verification_results;" "DROP TABLE matcher_tasks;" "DROP TABLE match_results;" @@ -151,6 +153,7 @@ static bool create_v10_database(const char *path) { } static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE geometric_verifier_tasks;" "DROP TABLE geometric_verification_results;" "DROP TABLE matcher_tasks;" "UPDATE metadata SET value=10 WHERE key='schema_version';" @@ -338,7 +341,7 @@ static bool run_test(void) { char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(database && lardon3d_project_db_schema_version(database) == 12); + CHECK(database && lardon3d_project_db_schema_version(database) == 13); bool legacy_pending = true; CHECK(lardon3d_project_db_legacy_catalog_pending(database, &legacy_pending) == LARDON3D_PROJECT_DB_OK && @@ -605,7 +608,7 @@ static bool run_test(void) { LARDON3D_PROJECT_DB_INVALID_ARGUMENT); lardon3d_project_db_close(contexts[0].database); database = NULL; - CHECK(query_integer(database_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(database_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(query_integer(database_path, "SELECT count(*) FROM tasks WHERE task_id=1", 1)); CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); CHECK(lardon3d_project_db_load_task(database, 1, &task) == LARDON3D_PROJECT_DB_OK); @@ -627,7 +630,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) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); CHECK(lardon3d_project_db_get_project(database, &loaded_project) == LARDON3D_PROJECT_DB_OK && strcmp(loaded_project.stable_id, "legacy-project") == 0); CHECK(lardon3d_project_db_load_task(database, 9, &task) == LARDON3D_PROJECT_DB_OK); @@ -637,7 +640,7 @@ static bool run_test(void) { LARDON3D_PROJECT_DB_OK); lardon3d_project_db_close(database); database = NULL; - CHECK(query_integer(legacy_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(legacy_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(create_v1_database(failed_migration_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V2", "1", 1) == 0); @@ -662,7 +665,7 @@ static bool run_test(void) { LARDON3D_PROJECT_DB_OK); lardon3d_project_db_close(database); database = NULL; - CHECK(query_integer(v2_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(v2_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(create_v2_database(failed_v3_migration_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V3", "1", 1) == 0); @@ -690,7 +693,7 @@ static bool run_test(void) { LARDON3D_PROJECT_DB_OK); lardon3d_project_db_close(database); database = NULL; - CHECK(query_integer(v3_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(v3_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(create_v3_database(failed_v4_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V4", "1", 1) == 0); @@ -707,7 +710,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) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); CHECK(lardon3d_project_db_load_task(database, 9, &task) == LARDON3D_PROJECT_DB_OK); CHECK(lardon3d_project_db_load_artifact(database, "legacy-artifact", &loaded_artifact) == LARDON3D_PROJECT_DB_OK); @@ -746,24 +749,24 @@ static bool run_test(void) { fprintf(stderr, "Nouvelle tentative migration v7 (%d): %s\n", (int)retry_v7, error); } CHECK(retry_v7 == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 12); + lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL; CHECK(create_v5_database(direct_v5_path)); CHECK(query_integer(direct_v5_path, "SELECT value FROM metadata WHERE key='schema_version'", 5)); CHECK(lardon3d_project_db_open(direct_v5_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 12); + lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL; CHECK(create_v7_database(v8_path)); CHECK(query_integer(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) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL; - CHECK(query_integer(v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(create_v7_database(failed_v8_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V8", "1", 1) == 0); @@ -774,7 +777,7 @@ static bool run_test(void) { "SELECT count(*) FROM sqlite_master WHERE type='table' AND " "name='candidate_pairs'", 0)); CHECK(lardon3d_project_db_open(failed_v8_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 12); + lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL; @@ -789,10 +792,10 @@ static bool run_test(void) { "name='matcher_tasks'", 0)); CHECK(lardon3d_project_db_open(v10_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 12); + lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL; - CHECK(query_integer(v10_path, "SELECT value FROM metadata WHERE key='schema_version'", 12)); + CHECK(query_integer(v10_path, "SELECT value FROM metadata WHERE key='schema_version'", 13)); CHECK(query_integer(v10_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " "name='matcher_tasks'", @@ -815,7 +818,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) == 12); + CHECK(lardon3d_project_db_schema_version(database) == 13); lardon3d_project_db_close(database); database = NULL;