From 2e0104162c1cb9cbd8e24d5f47bca691e934cb7f Mon Sep 17 00:00:00 2001 From: fy59 Date: Tue, 25 Aug 2026 11:56:41 +0200 Subject: [PATCH] feat(sparse-sfm): implement and freeze Gate F --- README.md | 8 +- docs/architecture/feature_store.md | 2 +- docs/architecture/overview.md | 5 + docs/architecture/project_database.md | 41 +- docs/architecture/reconstruction_pipeline.md | 12 +- docs/architecture/resource_boundary.md | 61 +- docs/architecture/sparse_sfm.md | 195 +++++- docs/architecture/task_kind_registry.md | 5 + docs/roadmap/roadmap.md | 1 + include/lardon3d/project_db.h | 20 +- include/lardon3d/sparse_sfm_task.h | 38 ++ meson.build | 60 +- src/project_db.c | 310 ++++++++- src/sparse_sfm_gate_f.cpp | 152 +++++ src/sparse_sfm_gate_f_internal.h | 23 + src/sparse_sfm_task.cpp | 667 +++++++++++++++++++ src/task_kinds.c | 10 + tests/test_geometric_verification.c | 9 +- tests/test_geometric_verifier_task.c | 3 +- tests/test_match_result.c | 11 +- tests/test_matcher_task.c | 3 +- tests/test_project_db.c | 52 +- tests/test_sparse_sfm_gate_f.cpp | 153 +++++ tests/test_sparse_sfm_model.c | 2 +- tests/test_sparse_sfm_payload.cpp | 142 ++++ tests/test_sparse_sfm_task.cpp | 368 ++++++++++ 26 files changed, 2275 insertions(+), 78 deletions(-) create mode 100644 include/lardon3d/sparse_sfm_task.h create mode 100644 src/sparse_sfm_gate_f.cpp create mode 100644 src/sparse_sfm_gate_f_internal.h create mode 100644 src/sparse_sfm_task.cpp create mode 100644 tests/test_sparse_sfm_gate_f.cpp create mode 100644 tests/test_sparse_sfm_payload.cpp create mode 100644 tests/test_sparse_sfm_task.cpp diff --git a/README.md b/README.md index 1781804..6b369fe 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,11 @@ persistante, enrichissable et versionnable. - **Image View** : vues triées et filtrées pour la TUI - **Task** : moteur de tâches avec pause/reprise, annulation et séquences - **Task Checkpoint v1** : snapshot durable, fichier atomique et reprise sûre -- **Project Database v16** : résultats géométriques, Track Model v1 et persistance Sparse SfM +- **Project Database v17** : résultats v16 et payload durable typé Sparse SfM Gate F - **Sparse SfM Gates C/D/E** : géométrie calibrée, noyau incrémental et Bundle Adjustment final par composante, tous PASS / FROZEN +- **Sparse SfM Gate F** : orchestration durable, runtime gouverné et publication + atomique, PASS / FROZEN - **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 @@ -169,8 +171,8 @@ de petits lots, le Resource Governor interactif et un hot path Vulkan ORB exact fallback CPU. La feasibility Vulkan SIFT/RootSIFT a été rejetée ; ces deux matchers restent sur OpenCV L2. Track Model/Builder, les primitives géométriques Gate C, le noyau Sparse SfM incrémental Gate D et le Bundle Adjustment final Gate E sont -implémentés et validés. L'orchestration Sparse SfM Gate F, l'intégration -Governor Gate G, le DAG, le viewer et les étapes denses restent des tickets +implémentés et validés. L'orchestration Sparse SfM Gate F est PASS / FROZEN ; +l'intégration Governor Gate G, le DAG, le viewer et les étapes denses restent des tickets séparés planifiés. Le Resource Governor ne constitue pas un Resource System générique : voir la décision d’architecture. diff --git a/docs/architecture/feature_store.md b/docs/architecture/feature_store.md index 9693c73..91df1ad 100644 --- a/docs/architecture/feature_store.md +++ b/docs/architecture/feature_store.md @@ -122,7 +122,7 @@ les Feature Sets persistés. PASS**, the Sparse SfM v16 persistence model is **FROZEN** after Gate B; Gate C geometry and the synchronous in-memory Gate D incremental core are **IMPLEMENTED / PASS**. Final per-component Gate E BA is **PASS / FROZEN**; -Gate F project/task orchestration remains **PLANNED**. +Gate F project/task orchestration is **PASS / FROZEN**. Gate G remains planned. ## Extension v2 multi-descriptor diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 2421039..5c00018 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -137,6 +137,11 @@ ni persistance, ni Task Runtime, ni Resource Governor. **Statut :** GATES C/D/E — PASS / FROZEN +Gate F relie ces noyaux au Task Runtime durable, à la Queue/Governor/Reservation +et à la publication Project DB v17 atomique et idempotente. + +**Statut :** GATE F — PASS / FROZEN. Gate G reste planifiée. + ## Résultats et publication live Les traitements fonctionnent par séquences adaptatives : lire un lot borné, diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 1605062..ddeb088 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -1,6 +1,45 @@ # Base de données projet Lardon3D -> Version courante : **v16**. La migration transactionnelle v15→v16 ajoute +## Gate F — décision Project DB v17 + +**PASS / FROZEN.** La migration historique v15→v16 et le +modèle de reconstruction Sparse SfM v16 restent inchangés. Gate F fait avancer +la tête de schéma à v17 par une migration strictement additive contenant une +seule table métier de tâche : `sparse_sfm_tasks`. + +Cette table suit le modèle des autres Task Kinds durables : une ligne par +`task_id`, clé primaire et clé étrangère vers `tasks(task_id)` avec suppression +en cascade. Elle conserve les références immuables `track_set_id` et +`calibration_scope_id`, `sfm_kind`, `sfm_version`, puis les 27 valeurs effectives +de `Lardon3DSparseIncrementalParameters`, y compris les sous-structures +relative-pose, PnP et refinement. Les entiers suivent leurs largeurs C +canoniques ; les flottants finis sont stockés en SQLite `REAL` et doivent +retrouver exactement leurs bits binary64 après fermeture/réouverture. + +Le Task Kind et sa version restent dans `tasks` et versionnent l'interprétation +du payload. Le fingerprint F0 est dérivé au rechargement et n'est pas dupliqué. +Le checkpoint générique v1 n'est pas modifié. L'écriture du résumé générique et +du payload typé est une transaction unique ; une ligne absente, incompatible +ou invalide interdit la reconstruction runtime sans appliquer de valeurs par +défaut. + +Les quatre champs `uint64_t` de domaine complet — limites observations/Tracks +et seeds déterministes relative-pose/PnP — sont chacun un BLOB de huit octets +little-endian avec `CHECK(length(...)=8)`. L'API exige la classe BLOB et la +longueur exacte au rechargement. Les métriques globales persistées restent les +diagnostics Gate F calculés sur toutes les observations retenues du résultat +Gate E final ; elles ne participent pas à l'identité. + +La publication Gate F est la projection durable du résultat scientifique +complet : une composante est persistée si et seulement si ses nombres d'images +enregistrées et de landmarks sont tous deux strictement positifs. Une +composante dont le BA est rejeté mais dont la géométrie Gate D reste valide est +persistée exactement ; seule une composante de graphe non reconstruite est +omise. Les comptes et métriques globaux décrivent exclusivement la géométrie +effectivement persistée. Aucun placeholder ni table diagnostique n'est ajouté. + +> Version courante : **v17**. La migration transactionnelle v16→v17 ajoute le +> payload durable typé `sparse_sfm_tasks`. La migration transactionnelle v15→v16 ajoute > le modèle persistant Sparse SfM (calibrations, scopes et reconstructions). > La migration transactionnelle v14→v15 ajoute > `track_builder_tasks` pour le payload durable explicite du Task Builder. La diff --git a/docs/architecture/reconstruction_pipeline.md b/docs/architecture/reconstruction_pipeline.md index 07da7a9..23ed939 100644 --- a/docs/architecture/reconstruction_pipeline.md +++ b/docs/architecture/reconstruction_pipeline.md @@ -127,7 +127,7 @@ implémenté dans Project DB v15 (`track_sets`, `tracks`, `track_observations` e le payload de tâche). Les primitives de géométrie calibrée Gate C sont implémentées et le noyau Sparse SfM incrémental Gate D est IMPLEMENTED / PASS ; le Bundle Adjustment final Gate E est PASS / FROZEN ; l'orchestration projet -et tâche Gate F reste PLANNED. +et tâche Gate F est PASS / FROZEN. Le modèle de persistance Sparse SfM v16 est gelé après Gate B. **Sparse SfM Gate A : PASS.** Le contrat géométrique, la stratégie @@ -136,8 +136,9 @@ les limites BA et l'enveloppe matérielle sont documentés dans `architecture/sparse_sfm.md`. Ses primitives pures calibrées Gate C sont **IMPLEMENTED / PASS** et son noyau incrémental synchrone en mémoire Gate D est **IMPLEMENTED / PASS**. Le Bundle Adjustment final par composante Gate E est -**PASS / FROZEN**. L'orchestration Gate F reste **PLANNED**, tandis que la -persistance v16 et ses lecteurs bornés restent ceux de B2. +**PASS / FROZEN**. L'orchestration Gate F est **PASS / FROZEN**. Le modèle de +reconstruction v16 et ses lecteurs bornés restent ceux de B2 ; le payload de +tâche Gate F est l'ajout v17. --- @@ -258,8 +259,9 @@ Import, Image Catalog, Feature Extraction, Feature Store, Visual Index, Candidate Pair, Matching v1, Geometric Verification, Track Model/Builder v1 and Sparse SfM Gate C geometry are **IMPLEMENTED**. The synchronous in-memory incremental Sparse SfM Gate D core is **IMPLEMENTED / PASS**, and final -per-component Gate E BA is **PASS / FROZEN**. Gate F orchestration, -Gate G Governor integration, MVS, mesh, texturing and viewer remain **PLANNED**. +per-component Gate E BA is **PASS / FROZEN**. Gate F orchestration is +**PASS / FROZEN**. Gate G Governor integration, MVS, mesh, +texturing and viewer remain **PLANNED**. Ce document décrit la vision architecturale cible du pipeline de reconstruction. Les modules listés ici ne sont pas tous implémentés. diff --git a/docs/architecture/resource_boundary.md b/docs/architecture/resource_boundary.md index 90c54bc..65c00ed 100644 --- a/docs/architecture/resource_boundary.md +++ b/docs/architecture/resource_boundary.md @@ -4,8 +4,9 @@ **ACCEPTED** — architecture decision for the post-Gate C documentation freeze. -Current gates: Gate A **PASS**, Gate B **PASS / FROZEN**, Gate C **PASS**. -Project Database: **v16**. +Current Sparse SfM gates A through F are **PASS / FROZEN**. Gate G remains +open/planned. +Project Database: current schema **v17**; historical v16 remains frozen. This record is normative for the current architecture. It does not introduce an implementation, a public API, a persistence format or a roadmap commitment. @@ -94,15 +95,47 @@ Tasks create estimates, request reservations, execute bounded work and release reservations. The Governor owns only policy, budgets, pressure, lots and reservations. -## Project DB v16 boundary +### Task demand declaration versus admission policy -Project DB v16 remains unchanged and frozen. It already stores the current -logical IDs, content hashes, sizes, states, relations and artifact paths needed -by implemented stores. No resource table, handle table, cache table, -dependency table, schema field or migration is required by this decision. +**FROZEN.** A task producer owns the immutable declaration of the workload it +submits through the existing `Lardon3DResourceEstimate` contract. Constructing +that estimate from immutable payload, input shape and known implementation +characteristics describes what the task expects to require; it is not a +Governor policy decision. -No Project DB v17, bundle redesign, Track Model change, Track Builder change or -persistence API redesign is part of this scope. +The existing Task Runtime, queue and Governor remain responsible for deciding +whether and when the declared work is admitted and for creating the mandatory +reservation. Admission thresholds, current machine state, pressure, telemetry, +adaptive tuning, swap or scratch policy and future estimate calibration remain +outside the task producer. A producer never changes scientific inputs or +identity in response to resources. + +Gate F may therefore construct the immutable Sparse SfM task estimate required +by `lardon3d_task_create_typed()` and submit the task through the normal queue. +It does not inspect resource snapshots, decide admission, create reservations, +change Governor or queue policy, or bypass the reservation invariant. The +estimate is operational metadata and is excluded from the Sparse SfM parameter +fingerprint, candidate identity and scientific determinism. Future Gate G owns +resource-management policy and operational refinement of that estimate. + +Gate F v1 freezes its declarative Sparse SfM RAM request as the checked sum +`128 MiB + I*64 KiB + T*2048 + O*512`, rounded upward to one MiB, where `I`, +`T` and `O` are immutable participating-image, Track and observation counts. +The complete amount is fixed RAM for one atomic batch; per-item RAM and all GPU +fields are zero, batch bounds are one, and the task requests one CPU thread and +one IO slot in the CPU class. These coefficients are conservative operational +policy inputs, not measured dynamically or included in scientific identity. + +## Project DB boundary + +The historical Project DB v16 migration and Sparse SfM reconstruction model +remain unchanged and frozen. Gate F is authorized to advance the current schema +head to v17 only with the dedicated `sparse_sfm_tasks` typed-payload table; that +task persistence does not add a resource table, handle table, cache table, +dependency table or resource-policy field. + +No further schema expansion, bundle redesign, Track Model change, Track Builder +change or persistence API redesign is part of this resource-boundary scope. ## Options considered @@ -164,7 +197,7 @@ resolver or Governor asset integration as part of the current architecture. - Generic dependency graph or cycle management. - Streaming or asynchronous resource loading. - CPU/GPU dual-residency manager. -- Project DB v17 or schema migration. +- Project DB schema beyond the additive Gate F v17 typed-task migration. - Bundle redesign. - Governor integration with persistent asset identity. - BA, new orchestration, Task Runtime redesign or renderer redesign. @@ -178,8 +211,9 @@ contracts remain authoritative in their own domains. ## Persistence impact -None. Project DB remains v16. Existing IDs, hashes, metadata and relative paths -are not renamed, generalized or duplicated. +The historical Project DB v16 migration remains unchanged. Gate F adds only the +v17 typed-task payload already described above. Existing IDs, hashes, metadata +and relative paths are not renamed, generalized or duplicated. ## Memory / resource impact @@ -239,7 +273,8 @@ These are trigger conditions, not current requirements or roadmap commitments. - No Track Model or Track Builder contract is changed. - No bundle or migration is introduced. - Governor ownership remains limited to execution budgets. -- Project DB remains v16. +- At the Gate B freeze, Project DB remained v16; the later additive Gate F v17 + migration does not alter that historical proof. Therefore Gate B remains **PASS / FROZEN**. diff --git a/docs/architecture/sparse_sfm.md b/docs/architecture/sparse_sfm.md index 3fbc0b7..430638b 100644 --- a/docs/architecture/sparse_sfm.md +++ b/docs/architecture/sparse_sfm.md @@ -198,7 +198,7 @@ as a whole, so Track identity and observation ownership remain simple. Bundle Adjustment implementation, E01--E35 matrix, normal suite, targeted ASan/UBSan with LeakSanitizer, full sequential ASan/UBSan suite and at least 20 fresh-process E27 comparisons are validated. Gate F project orchestration -and Gate G resource integration remain later gates. +is now **PASS / FROZEN**; Gate G resource integration remains a later gate. **DECISION: Gate E v1 is a synchronous, independent final per-component Bundle Adjustment applied as post-processing to a copy of the immutable final Gate D @@ -779,6 +779,185 @@ solver until measured. The v1 target is deterministic ordering and numerical reproducibility within documented tolerances; single-threaded reductions are the initial reference. +### Gate F publication policy + +**FROZEN.** Gate F keeps the Gate E execution domain separate from the Gate E +scientific result domain. `EXECUTION_OK` is the necessary and sufficient Gate E +condition for publication eligibility. Under that execution status, +`COMPLETE`, `PARTIAL` and `FAILED` are all valid complete in-memory Gate E +results and are published atomically. A successful Project DB publication or +exact-identity reuse makes the Task Runtime execution successful for all three +scientific statuses. + +| Gate E execution | Scientific status | Publish | Runtime after DB success | +|---|---|---|---| +| `EXECUTION_OK` | `COMPLETE` | exact Gate E result | success | +| `EXECUTION_OK` | `PARTIAL` | exact Gate E result | success | +| `EXECUTION_OK` | `FAILED` | exact Gate E result | success | +| `EXECUTION_INVALID_ARGUMENT` | none | no publication | failure | +| `EXECUTION_OUT_OF_MEMORY` | none | no publication | failure | +| `EXECUTION_INTERNAL_ERROR` | none | no publication | failure | + +Scientific `PARTIAL` describes a complete result in which accepted components +contain validated optimized values and rejected components preserve their exact +Gate D values. It never permits partial database visibility. Scientific +`FAILED` means that no eligible component accepted Bundle Adjustment; under +`EXECUTION_OK` it remains a valid complete Gate E result whose components +preserve the Gate D values selected by Gate E. + +Gate F never publishes Gate D as a fallback after a Gate E execution error. It +does not rerun Gate D or Gate E, retry automatically, reinterpret scientific +status as a Task Runtime state, or require scientific `COMPLETE` for runtime +success. A Project DB publication failure rolls back and fails the runtime +execution. The scientific status is output diagnostic metadata, not part of +the candidate identity or parameter fingerprint. Gate F uses an existing +status carrier when one exists; the absence of a dedicated Project DB v16 +column does not require a migration or authorize reuse of an unrelated column. + +### Gate F resource-demand boundary + +**FROZEN.** Gate F materializes the immutable `Lardon3DResourceEstimate` needed +to describe its task to the existing Task Runtime. The estimate is a pure +function of durable task input shape and known implementation characteristics; +it never depends on current RAM, swap, PSI, load, queue depth, task attempt or +Governor state. Gate F submits through the existing queue and does not perform +admission or reservation itself. + +The existing Governor owns admission and reservation policy. Future Gate G owns +telemetry, adaptive tuning, pressure and swap/scratch policy, scheduling policy +and estimate refinement. The estimate never enters the 372-byte parameter +record, candidate identity, `sfm_version` or scientific decisions, and cannot +change Gate D or Gate E parameters. This clarification preserves +`NO_NEW_SUBSYSTEM` and the mandatory reservation invariant. + +### Gate F durable task payload + +**FROZEN.** Gate F advances the current Project Database schema head from v16 +to v17 with one strictly additive `sparse_sfm_tasks` table. The historical v16 +migration and its immutable reconstruction model remain unchanged. The new +table follows the existing one-to-one typed-task pattern: its primary key is a +foreign key to `tasks(task_id)` with cascade cleanup, and creation records the +generic task snapshot and typed payload in one transaction. + +The payload stores the immutable Track Set reference, calibration-scope +reference, Sparse SfM kind and version, and every one of the 27 effective +scalars in `Lardon3DSparseIncrementalParameters`, using their existing fixed +integer widths and exact finite SQLite binary64 values. The Task Kind version +selects the payload interpretation. Reload never reapplies defaults and rejects +a missing row, incompatible kind/version, invalid identity or invalid +parameter. The generic checkpoint codec remains v1 and unchanged. + +The parameter fingerprint is not stored in `sparse_sfm_tasks`. Reconstruction +loads and validates the effective fields, rebuilds the unchanged 372-byte F0 +record and recomputes SHA-256. Calibration values, Track observations, +ResourceEstimate and runtime metadata are not duplicated in the typed payload. +This is an additive Project DB/typed-Task extension, not a generic payload or +persistence subsystem. + +### Gate F closure decisions + +**FROZEN.** The v1 declarative estimate uses the immutable pre-admission counts +`I` (distinct participating images), `T` (Tracks) and `O` (Track observations): + +```text +raw = 134217728 + I*65536 + T*2048 + O*512 +memory_fixed_bytes = raw rounded upward to a whole MiB +``` + +Every operation is checked `uint64_t` arithmetic; overflow rejects task +creation before persistence. The remaining estimate is RAM-per-item 0, all GPU +fields 0, minimum and maximum batch 1, one CPU thread, one IO slot and CPU task +class. These conservative coefficients cover Gate D containers, adapters, +camera/landmark state, Gate E copies, ordering/residual storage and Ceres +working storage. They are operational, machine-state independent and excluded +from scientific identity. A restored task uses its persisted generic estimate. + +The four full-domain `uint64_t` payload values (`maximum_observations`, +`maximum_tracks` and both deterministic seeds) are individual exact eight-byte +little-endian SQL BLOBs. Any other storage class or length is corrupt; there is +no signed cast, text, REAL conversion or domain restriction. Their F0 encoding +remains unchanged. + +Gate D `COMPLETE` and usable `PARTIAL` results proceed to Gate E. Gate D +scientific `FAILED` fails the Task, invokes no Gate E and publishes nothing. +Gate D invalid input or allocation failure likewise fails execution. A claimed +usable result that violates frozen structure is an internal integration +failure. Once Gate E is legitimately reached, every `EXECUTION_OK` scientific +status remains publication-eligible. + +Gate F computes publication diagnostics from every retained observation of the +exact final Gate E result. With source-pixel residual `dx,dy`, each observation +contributes `s=dx*dx+dy*dy` and `e=sqrt(s)`. Global RMSE is +`sqrt(sum(s)/N)`. Global median is the middle sorted `e`, or for even `N`, +`lower + (upper-lower)/2`. Projection is the frozen Gate E distorted pinhole +model, binary64, with strict minimum depth. Empty, non-finite or invalid +projection input fails publication; no observation is skipped or clamped. +Metrics are deterministic result diagnostics, never candidate identity. + +### Gate F durable reconstruction projection + +**FROZEN.** Gate F projects the complete Gate D/E scientific result onto the +existing Project DB reconstruction model. A component belongs to the durable +projection exactly when `registered_image_count > 0` and `landmark_count > 0`. +Every such component is persisted with its exact final Gate E geometry. This +includes a BA-rejected component whose valid Gate D geometry Gate E preserved. + +A non-reconstructed graph component failing either predicate remains an +ephemeral scientific/orchestration diagnostic and has no Project DB component +row. Gate F fabricates no camera, landmark or placeholder and does not fail an +otherwise publishable reconstruction merely because such diagnostics exist. +The projected result must still satisfy every top-level Project DB invariant; +otherwise publication does not occur and the Task fails. + +Persisted component and geometry counts describe only this durable projection. +Global reprojection metrics likewise include exactly the retained observations +belonging to persisted geometry. Omission neither renumbers scientific +component keys nor changes candidate identity. No persistent diagnostic table, +sidecar, metadata blob or schema beyond v17 `sparse_sfm_tasks` is introduced. + +Gate F v1 is **PASS / FROZEN**. Gate D and Gate E remain **PASS / FROZEN**; +Gate G remains open and planned. + +### Gate F validation closure + +**PASS / FROZEN.** Gate F freezes the F0 372-byte parameter record v1 and its +SHA-256 digest, Project DB v17 typed payload, `sparse_sfm.run` version 1, +pre-admission declarative estimate, governed Task Runtime execution, durable +replay, deterministic D→E orchestration, exact candidate reuse and atomic +reconstruction publication. Canonical gate progression: + +```text +Gate A — PASS / FROZEN +Gate B — PASS / FROZEN +Gate C — PASS / FROZEN +Gate D — PASS / FROZEN +Gate E — PASS / FROZEN +Gate F — PASS / FROZEN +Gate G — OPEN / PLANNED +``` + +The five candidate-identity dimensions remain separate: + +```text +( + input_track_set_identity, + calibration_scope_identity, + sfm_kind, + sfm_version, + parameter_fingerprint +) +``` + +The F0 golden SHA-256 digest remains +`e1c83e5b2036e49254a9426ddbace42b7831373bc896f27abdd2f61e302f9e8c`. +Final validation completed with the normal suite at 41/41, targeted Gate F +ASan/UBSan/LeakSanitizer at 4/4 with leak detection enabled, and the full +sequential ASan/UBSan suite at 41/41. Fresh-process validation passed 20/20 for +the Gate F contract and 20/20 for the production `sparse_sfm.run` task. The C17 +public-header probe and `git diff --check` passed. Final human diff review +passed; no Gate F implementation work, validation work or human decision +remains. + ## Future persistence and API candidates No Project DB v16 is created in Gate A. A later model gate may define immutable @@ -831,21 +1010,21 @@ reported separately. Thread probes are limited to 1/2/4/8 threads and stop if MemAvailable, swap, PSI or desktop responsiveness becomes unhealthy. No production Sparse SfM code is created by this gate. -## Future gate plan +## Gate decomposition -- **Gate B — Sparse Reconstruction Model:** immutable in-memory model, result +- **Gate B — PASS / FROZEN — Sparse Reconstruction Model:** immutable in-memory model, result states, calibration ownership and candidate persistence contract; no DB v16 until this contract is reviewed. -- **Gate C — Geometry primitives:** normalized camera model, relative pose, +- **Gate C — PASS / FROZEN — Geometry primitives:** normalized camera model, relative pose, deterministic seed, triangulation and PnP with synthetic ground truth. -- **Gate D — Incremental core:** registration ordering, components, +- **Gate D — PASS / FROZEN — Incremental core:** registration ordering, components, unregistered-image policy and deterministic reconstruction output. -- **Gate E — Final Bundle Adjustment:** synchronous final per-component BA on a +- **Gate E — PASS / FROZEN — Final Bundle Adjustment:** synchronous final per-component BA on a copy of the immutable Gate D result, with its scientific and numerical contract frozen here; interleaved local BA is deferred. -- **Gate F — Project orchestration:** explicit Track Set/calibration input, +- **Gate F — PASS / FROZEN — Project orchestration:** explicit Track Set/calibration input, atomic publication and durable runtime integration. -- **Gate G — Resource/freeze:** Governor admission, sustained hardware safety, +- **Gate G — OPEN / PLANNED — Resource/freeze:** Governor admission, sustained hardware safety, recovery, full validation and final freeze. ## Algorithm comparison and Gate A evidence diff --git a/docs/architecture/task_kind_registry.md b/docs/architecture/task_kind_registry.md index d48f65e..11abac0 100644 --- a/docs/architecture/task_kind_registry.md +++ b/docs/architecture/task_kind_registry.md @@ -79,3 +79,8 @@ générique v1 ne possède aucun payload propre au kind. depuis son payload Project DB v15 et son asset little-endian validé. Le callback réutilise l'orchestration Gate C et le reconstructeur refuse toute corruption, version, fingerprint, checksum, tri, unicité ou L3DTSIS1 incohérents. + +**PASS / FROZEN** — `sparse_sfm.run`, version 1, recharge le +payload scientifique explicite Project DB v17, restaure l'estimation générique +persistée et rejoue D puis E depuis les références Track Set/calibration. Le +fingerprint F0 est recalculé ; le checkpoint générique v1 reste inchangé. diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 56a52a3..51945d4 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -53,6 +53,7 @@ Lardon3D suit une feuille de route ordonnée qui privilégie la stabilité et la - ✅ Sparse SfM : primitives géométriques Gate C et noyau incrémental Gate D implémentés - ✅ Sparse SfM Gate E : Bundle Adjustment final par composante PASS / FROZEN +- ✅ Sparse SfM Gate F : orchestration durable et publication atomique PASS / FROZEN ### Phase 5 : Reconstruction (PLANNED) - 📋 Orchestration de reconstruction incrémentale diff --git a/include/lardon3d/project_db.h b/include/lardon3d/project_db.h index 7983efa..cfaf50a 100644 --- a/include/lardon3d/project_db.h +++ b/include/lardon3d/project_db.h @@ -7,9 +7,10 @@ #include #include +#include enum { - LARDON3D_PROJECT_DB_SCHEMA_VERSION = 16, + LARDON3D_PROJECT_DB_SCHEMA_VERSION = 17, LARDON3D_PROJECT_DB_ID_CAPACITY = 65, LARDON3D_PROJECT_DB_KIND_CAPACITY = 65, LARDON3D_PROJECT_DB_PATH_CAPACITY = 4096, @@ -391,6 +392,15 @@ typedef struct { int64_t created_at; } Lardon3DProjectDbTrackSet; +typedef struct { + uint64_t task_id; + uint64_t track_set_id; + uint64_t calibration_scope_id; + uint32_t sfm_kind; + uint32_t sfm_version; + Lardon3DSparseIncrementalParameters parameters; +} Lardon3DProjectDbSparseSfmTask; + void lardon3d_project_db_free_track(Lardon3DProjectDbTrack *track); Lardon3DProjectDbResult lardon3d_project_db_create_track_set( Lardon3DProjectDb *database, const Lardon3DProjectDbTrackSet *configuration, @@ -602,6 +612,14 @@ Lardon3DProjectDbResult lardon3d_project_db_record_track_builder_task( Lardon3DProjectDbResult lardon3d_project_db_load_track_builder_task( Lardon3DProjectDb *database, uint64_t task_id, Lardon3DProjectDbTrackBuilderTask *parameters); +Lardon3DProjectDbResult lardon3d_project_db_record_sparse_sfm_task( + Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, + const char *kind, uint32_t version, + const Lardon3DProjectDbCheckpoint *checkpoint, + const Lardon3DProjectDbSparseSfmTask *parameters, int64_t updated_at); +Lardon3DProjectDbResult lardon3d_project_db_load_sparse_sfm_task( + Lardon3DProjectDb *database, uint64_t task_id, + Lardon3DProjectDbSparseSfmTask *parameters); Lardon3DProjectDbResult lardon3d_project_db_create_match_result( Lardon3DProjectDb *database, uint64_t candidate_pair_id, uint64_t feature_set_id_a, diff --git a/include/lardon3d/sparse_sfm_task.h b/include/lardon3d/sparse_sfm_task.h new file mode 100644 index 0000000..92c4ebd --- /dev/null +++ b/include/lardon3d/sparse_sfm_task.h @@ -0,0 +1,38 @@ +#ifndef LARDON3D_SPARSE_SFM_TASK_H +#define LARDON3D_SPARSE_SFM_TASK_H + +#include +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LARDON3D_SPARSE_SFM_TASK_KIND "sparse_sfm.run" + +enum { LARDON3D_SPARSE_SFM_TASK_KIND_VERSION = 1 }; + +typedef struct { + uint64_t track_set_id; + uint64_t calibration_scope_id; + Lardon3DSparseIncrementalParameters parameters; +} Lardon3DSparseSfmTaskConfiguration; + +Lardon3DTask *lardon3d_project_create_sparse_sfm_task( + Lardon3DAppState *state, const Lardon3DSparseSfmTaskConfiguration *configuration, + uint64_t *task_id); +bool lardon3d_project_enqueue_sparse_sfm_task( + Lardon3DAppState *state, const Lardon3DSparseSfmTaskConfiguration *configuration, + uint64_t *task_id); +bool lardon3d_sparse_sfm_task_reconstruct(const Lardon3DTaskDurableSnapshot *snapshot, + void *context, Lardon3DTaskKindBinding *binding); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/meson.build b/meson.build index e895a55..f8f8358 100644 --- a/meson.build +++ b/meson.build @@ -153,6 +153,8 @@ executable( 'src/sparse_sfm_geometry.cpp', 'src/sparse_sfm_incremental.cpp', 'src/sparse_sfm_bundle_adjustment.cpp', + 'src/sparse_sfm_gate_f.cpp', + 'src/sparse_sfm_task.cpp', 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', @@ -169,7 +171,10 @@ executable( 'src/track_builder_task.cpp', ] + matcher_backend_sources, include_directories: include_directories('include'), - c_args: ['-DLARDON3D_TRACK_BUILDER_TASK_AVAILABLE'], + c_args: [ + '-DLARDON3D_TRACK_BUILDER_TASK_AVAILABLE', + '-DLARDON3D_SPARSE_SFM_TASK_AVAILABLE', + ], dependencies: [ncursesw, threads, sqlite3, openssl, opencv, opencv_geometry, ceres] + matcher_backend_dependencies, ) @@ -918,6 +923,59 @@ geometric_verifier_task_test = executable( test('geometric-verifier-task', geometric_verifier_task_test, timeout: 60, env: opencv_test_environment) +sparse_sfm_gate_f_test = executable( + 'test-sparse-sfm-gate-f', + sources: [ + 'tests/test_sparse_sfm_gate_f.cpp', + 'src/sparse_sfm_gate_f.cpp', + 'src/sparse_sfm_incremental.cpp', + 'src/sparse_sfm_geometry.cpp', + ], + include_directories: include_directories('include'), + dependencies: [openssl, opencv_geometry], +) +test('sparse-sfm-gate-f', sparse_sfm_gate_f_test, timeout: 60) + +sparse_sfm_task_test = executable( + 'test-sparse-sfm-task', + sources: [ + 'tests/test_sparse_sfm_task.cpp', + 'src/sparse_sfm_task.cpp', + 'src/sparse_sfm_gate_f.cpp', + 'src/sparse_sfm_incremental.cpp', + 'src/sparse_sfm_geometry.cpp', + 'src/sparse_sfm_bundle_adjustment.cpp', + 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/feature_store.c', 'src/feature_extractor_opencv.cpp', + 'src/app_state.c', + 'src/task.c', 'src/task_checkpoint.c', 'src/task_kind_registry.c', + 'src/task_queue.c', + 'src/resource_governor.c', 'src/resource_snapshot.c', + ], + cpp_args: ['-DLARDON3D_SPARSE_SFM_TASK_TESTING'], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv, opencv_geometry, ceres], +) +test('sparse-sfm-task', sparse_sfm_task_test, timeout: 120) + +sparse_sfm_payload_test = executable( + 'test-sparse-sfm-payload', + sources: [ + 'tests/test_sparse_sfm_payload.cpp', + 'src/project_db.c', + 'src/project_db_sparse_sfm.c', + 'src/task.c', + 'src/task_kind_registry.c', + 'src/resource_governor.c', + 'src/resource_snapshot.c', + 'src/sparse_sfm_incremental.cpp', + 'src/sparse_sfm_geometry.cpp', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv_geometry], +) +test('sparse-sfm-payload', sparse_sfm_payload_test, timeout: 60) + track_builder_test = executable( 'test-track-builder-core', sources: ['tests/test_track_builder_core.cpp', 'src/track_builder.cpp'], diff --git a/src/project_db.c b/src/project_db.c index aaa8099..5f7b5e4 100644 --- a/src/project_db.c +++ b/src/project_db.c @@ -423,6 +423,30 @@ static const char schema_sparse_sfm_v16_b[] = "CREATE INDEX sparse_landmark_observations_page_idx ON " "sparse_landmark_observations(landmark_id,position_in_track);"; +static const char schema_sparse_sfm_task_v17[] = + "CREATE TABLE sparse_sfm_tasks(" + "task_id INTEGER PRIMARY KEY REFERENCES tasks(task_id) ON DELETE CASCADE," + "track_set_id INTEGER NOT NULL REFERENCES track_sets(track_set_id)," + "calibration_scope_id INTEGER NOT NULL REFERENCES sparse_calibration_scopes(scope_id)," + "sfm_kind INTEGER NOT NULL CHECK(sfm_kind=1)," + "sfm_version INTEGER NOT NULL CHECK(sfm_version=1)," + "minimum_seed_tracks INTEGER NOT NULL,minimum_seed_landmarks INTEGER NOT NULL," + "minimum_pnp_correspondences INTEGER NOT NULL,maximum_seed_candidates INTEGER NOT NULL," + "maximum_registration_rounds INTEGER NOT NULL,maximum_landmarks_per_round INTEGER NOT NULL," + "maximum_images INTEGER NOT NULL,maximum_observations BLOB NOT NULL " + "CHECK(length(maximum_observations)=8),maximum_tracks BLOB NOT NULL " + "CHECK(length(maximum_tracks)=8),reprojection_threshold_px REAL NOT NULL," + "minimum_track_parallax_rad REAL NOT NULL,relative_robust_threshold_px REAL NOT NULL," + "relative_confidence REAL NOT NULL,relative_max_iterations INTEGER NOT NULL," + "relative_minimum_inliers INTEGER NOT NULL,relative_minimum_inlier_ratio REAL NOT NULL," + "relative_minimum_parallax_rad REAL NOT NULL,relative_minimum_cheirality_ratio REAL NOT NULL," + "relative_deterministic_seed BLOB NOT NULL CHECK(length(relative_deterministic_seed)=8)," + "pnp_reprojection_threshold_px REAL NOT NULL,pnp_confidence REAL NOT NULL," + "pnp_max_iterations INTEGER NOT NULL,pnp_minimum_inliers INTEGER NOT NULL," + "pnp_minimum_inlier_ratio REAL NOT NULL,pnp_deterministic_seed BLOB NOT NULL " + "CHECK(length(pnp_deterministic_seed)=8),refinement_max_iterations INTEGER NOT NULL," + "refinement_convergence_tolerance REAL NOT NULL);"; + 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 : ""); @@ -839,6 +863,23 @@ static Lardon3DProjectDbResult migrate(Lardon3DProjectDb *database, unsigned int "finish schema v16 migration"); } } + if (result == LARDON3D_PROJECT_DB_OK && from_version < 17) { + result = execute(database, schema_sparse_sfm_task_v17, + "migrate schema v16 to v17"); +#ifdef LARDON3D_PROJECT_DB_TESTING + if (result == LARDON3D_PROJECT_DB_OK && + getenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V17")) { + result = execute(database, "INSERT INTO missing_v17_test_table VALUES(1)", + "forced migration v17 failure"); + } +#endif + if (result == LARDON3D_PROJECT_DB_OK) { + result = execute( + database, + "UPDATE metadata SET value=17 WHERE key='schema_version' AND value=16", + "finish schema v17 migration"); + } + } if (result == LARDON3D_PROJECT_DB_OK) { result = execute(database, "COMMIT", "commit migration"); } @@ -1150,6 +1191,36 @@ static bool valid_durable_task(const Lardon3DTaskDurableSnapshot *snapshot, int6 updated_at >= 0; } +static bool valid_sparse_sfm_parameters(const Lardon3DSparseIncrementalParameters *p) { + return p && p->minimum_seed_tracks >= 2 && p->minimum_seed_landmarks > 0 && + p->minimum_pnp_correspondences >= 4 && p->maximum_seed_candidates > 0 && + p->maximum_registration_rounds > 0 && p->maximum_landmarks_per_round > 0 && + p->maximum_images >= 2 && p->maximum_observations >= p->maximum_tracks && + isfinite(p->reprojection_threshold_px) && p->reprojection_threshold_px > 0.0 && + isfinite(p->minimum_track_parallax_rad) && p->minimum_track_parallax_rad >= 0.0 && + isfinite(p->relative_pose.robust_threshold_px) && + p->relative_pose.robust_threshold_px > 0.0 && isfinite(p->relative_pose.confidence) && + p->relative_pose.confidence > 0.0 && p->relative_pose.confidence < 1.0 && + p->relative_pose.max_iterations > 0 && + p->relative_pose.minimum_inliers >= p->minimum_seed_tracks && + isfinite(p->relative_pose.minimum_inlier_ratio) && + p->relative_pose.minimum_inlier_ratio > 0.0 && + p->relative_pose.minimum_inlier_ratio <= 1.0 && + isfinite(p->relative_pose.minimum_parallax_rad) && + p->relative_pose.minimum_parallax_rad >= 0.0 && + isfinite(p->relative_pose.minimum_cheirality_ratio) && + p->relative_pose.minimum_cheirality_ratio > 0.0 && + p->relative_pose.minimum_cheirality_ratio <= 1.0 && + isfinite(p->pnp.reprojection_threshold_px) && p->pnp.reprojection_threshold_px > 0.0 && + isfinite(p->pnp.confidence) && p->pnp.confidence > 0.0 && p->pnp.confidence < 1.0 && + p->pnp.max_iterations > 0 && + p->pnp.minimum_inliers >= p->minimum_pnp_correspondences && + isfinite(p->pnp.minimum_inlier_ratio) && p->pnp.minimum_inlier_ratio > 0.0 && + p->pnp.minimum_inlier_ratio <= 1.0 && p->refinement.max_iterations > 0 && + isfinite(p->refinement.convergence_tolerance) && + p->refinement.convergence_tolerance > 0.0; +} + static Lardon3DProjectDbResult record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, const char *task_kind, uint32_t task_kind_version, @@ -1160,6 +1231,7 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps const Lardon3DProjectDbCandidatePairGenerateTask *candidate_pair, const Lardon3DProjectDbMatcherTask *matcher, const Lardon3DProjectDbGeometricVerifierTask *geometric_verifier, + const Lardon3DProjectDbSparseSfmTask *sparse_sfm, int64_t updated_at) { bool typed = task_kind != NULL; if (!database || !valid_durable_task(snapshot, updated_at) || @@ -1220,6 +1292,12 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps geometric_verifier->min_inlier_ratio > 1.0 || geometric_verifier->seed_policy_version == 0 || geometric_verifier->canonicalization_version == 0)) || + (sparse_sfm && + (!task_kind || strcmp(task_kind, "sparse_sfm.run") != 0 || task_kind_version != 1 || + !valid_task_id(sparse_sfm->task_id) || sparse_sfm->task_id != snapshot->id || + !valid_task_id(sparse_sfm->track_set_id) || + !valid_task_id(sparse_sfm->calibration_scope_id) || sparse_sfm->sfm_kind != 1 || + sparse_sfm->sfm_version != 1 || !valid_sparse_sfm_parameters(&sparse_sfm->parameters))) || (checkpoint && !valid_checkpoint(checkpoint))) { return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } @@ -1489,6 +1567,103 @@ record_task_internal(Lardon3DProjectDb *database, const Lardon3DTaskDurableSnaps } } } + if (result == LARDON3D_PROJECT_DB_OK && sparse_sfm) { + const Lardon3DSparseIncrementalParameters *p = &sparse_sfm->parameters; + unsigned char maximum_observations[8]; + unsigned char maximum_tracks[8]; + unsigned char relative_seed[8]; + unsigned char pnp_seed[8]; + for (unsigned int i = 0; i < 8; ++i) { + maximum_observations[i] = (unsigned char)(p->maximum_observations >> (8U * i)); + maximum_tracks[i] = (unsigned char)(p->maximum_tracks >> (8U * i)); + relative_seed[i] = (unsigned char)(p->relative_pose.deterministic_seed >> (8U * i)); + pnp_seed[i] = (unsigned char)(p->pnp.deterministic_seed >> (8U * i)); + } + result = prepare( + database, + "INSERT INTO sparse_sfm_tasks(task_id,track_set_id,calibration_scope_id,sfm_kind," + "sfm_version,minimum_seed_tracks,minimum_seed_landmarks,minimum_pnp_correspondences," + "maximum_seed_candidates,maximum_registration_rounds,maximum_landmarks_per_round," + "maximum_images,maximum_observations,maximum_tracks,reprojection_threshold_px," + "minimum_track_parallax_rad,relative_robust_threshold_px,relative_confidence," + "relative_max_iterations,relative_minimum_inliers,relative_minimum_inlier_ratio," + "relative_minimum_parallax_rad,relative_minimum_cheirality_ratio," + "relative_deterministic_seed,pnp_reprojection_threshold_px,pnp_confidence," + "pnp_max_iterations,pnp_minimum_inliers,pnp_minimum_inlier_ratio," + "pnp_deterministic_seed,refinement_max_iterations,refinement_convergence_tolerance) " + "VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19," + "?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32) ON CONFLICT(task_id) DO " + "UPDATE SET track_set_id=excluded.track_set_id WHERE sparse_sfm_tasks.track_set_id=" + "excluded.track_set_id AND sparse_sfm_tasks.calibration_scope_id=" + "excluded.calibration_scope_id AND sparse_sfm_tasks.sfm_kind=excluded.sfm_kind AND " + "sparse_sfm_tasks.sfm_version=excluded.sfm_version AND sparse_sfm_tasks." + "minimum_seed_tracks=excluded.minimum_seed_tracks AND sparse_sfm_tasks." + "minimum_seed_landmarks=excluded.minimum_seed_landmarks AND sparse_sfm_tasks." + "minimum_pnp_correspondences=excluded.minimum_pnp_correspondences AND sparse_sfm_tasks." + "maximum_seed_candidates=excluded.maximum_seed_candidates AND sparse_sfm_tasks." + "maximum_registration_rounds=excluded.maximum_registration_rounds AND sparse_sfm_tasks." + "maximum_landmarks_per_round=excluded.maximum_landmarks_per_round AND sparse_sfm_tasks." + "maximum_images=excluded.maximum_images AND sparse_sfm_tasks.maximum_observations=" + "excluded.maximum_observations AND sparse_sfm_tasks.maximum_tracks=excluded.maximum_tracks " + "AND sparse_sfm_tasks.reprojection_threshold_px=excluded.reprojection_threshold_px AND " + "sparse_sfm_tasks.minimum_track_parallax_rad=excluded.minimum_track_parallax_rad AND " + "sparse_sfm_tasks.relative_robust_threshold_px=excluded.relative_robust_threshold_px AND " + "sparse_sfm_tasks.relative_confidence=excluded.relative_confidence AND sparse_sfm_tasks." + "relative_max_iterations=excluded.relative_max_iterations AND sparse_sfm_tasks." + "relative_minimum_inliers=excluded.relative_minimum_inliers AND sparse_sfm_tasks." + "relative_minimum_inlier_ratio=excluded.relative_minimum_inlier_ratio AND sparse_sfm_tasks." + "relative_minimum_parallax_rad=excluded.relative_minimum_parallax_rad AND sparse_sfm_tasks." + "relative_minimum_cheirality_ratio=excluded.relative_minimum_cheirality_ratio AND " + "sparse_sfm_tasks.relative_deterministic_seed=excluded.relative_deterministic_seed AND " + "sparse_sfm_tasks.pnp_reprojection_threshold_px=excluded.pnp_reprojection_threshold_px AND " + "sparse_sfm_tasks.pnp_confidence=excluded.pnp_confidence AND sparse_sfm_tasks." + "pnp_max_iterations=excluded.pnp_max_iterations AND sparse_sfm_tasks." + "pnp_minimum_inliers=excluded.pnp_minimum_inliers AND sparse_sfm_tasks." + "pnp_minimum_inlier_ratio=excluded.pnp_minimum_inlier_ratio AND sparse_sfm_tasks." + "pnp_deterministic_seed=excluded.pnp_deterministic_seed AND sparse_sfm_tasks." + "refinement_max_iterations=excluded.refinement_max_iterations AND sparse_sfm_tasks." + "refinement_convergence_tolerance=excluded.refinement_convergence_tolerance", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)sparse_sfm->task_id); + sqlite3_bind_int64(statement, 2, (sqlite3_int64)sparse_sfm->track_set_id); + sqlite3_bind_int64(statement, 3, (sqlite3_int64)sparse_sfm->calibration_scope_id); + sqlite3_bind_int(statement, 4, (int)sparse_sfm->sfm_kind); + sqlite3_bind_int(statement, 5, (int)sparse_sfm->sfm_version); + sqlite3_bind_int64(statement, 6, p->minimum_seed_tracks); + sqlite3_bind_int64(statement, 7, p->minimum_seed_landmarks); + sqlite3_bind_int64(statement, 8, p->minimum_pnp_correspondences); + sqlite3_bind_int64(statement, 9, p->maximum_seed_candidates); + sqlite3_bind_int64(statement, 10, p->maximum_registration_rounds); + sqlite3_bind_int64(statement, 11, p->maximum_landmarks_per_round); + sqlite3_bind_int64(statement, 12, p->maximum_images); + sqlite3_bind_blob(statement, 13, maximum_observations, 8, SQLITE_TRANSIENT); + sqlite3_bind_blob(statement, 14, maximum_tracks, 8, SQLITE_TRANSIENT); + sqlite3_bind_double(statement, 15, p->reprojection_threshold_px); + sqlite3_bind_double(statement, 16, p->minimum_track_parallax_rad); + sqlite3_bind_double(statement, 17, p->relative_pose.robust_threshold_px); + sqlite3_bind_double(statement, 18, p->relative_pose.confidence); + sqlite3_bind_int64(statement, 19, p->relative_pose.max_iterations); + sqlite3_bind_int64(statement, 20, p->relative_pose.minimum_inliers); + sqlite3_bind_double(statement, 21, p->relative_pose.minimum_inlier_ratio); + sqlite3_bind_double(statement, 22, p->relative_pose.minimum_parallax_rad); + sqlite3_bind_double(statement, 23, p->relative_pose.minimum_cheirality_ratio); + sqlite3_bind_blob(statement, 24, relative_seed, 8, SQLITE_TRANSIENT); + sqlite3_bind_double(statement, 25, p->pnp.reprojection_threshold_px); + sqlite3_bind_double(statement, 26, p->pnp.confidence); + sqlite3_bind_int64(statement, 27, p->pnp.max_iterations); + sqlite3_bind_int64(statement, 28, p->pnp.minimum_inliers); + sqlite3_bind_double(statement, 29, p->pnp.minimum_inlier_ratio); + sqlite3_bind_blob(statement, 30, pnp_seed, 8, SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 31, p->refinement.max_iterations); + sqlite3_bind_double(statement, 32, p->refinement.convergence_tolerance); + result = step_done(database, statement, "upsert sparse SfM task"); + if (result == LARDON3D_PROJECT_DB_OK && sqlite3_changes(database->connection) != 1) { + copy_error(database->error, "Payload Sparse SfM immuable."); + result = LARDON3D_PROJECT_DB_CONSTRAINT; + } + } + } if (result == LARDON3D_PROJECT_DB_OK && checkpoint) { result = prepare(database, @@ -1521,7 +1696,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_task( Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, const char *task_kind, uint32_t task_kind_version, const Lardon3DProjectDbCheckpoint *checkpoint, int64_t updated_at) { return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, NULL, 0, - NULL, NULL, NULL, NULL, NULL, NULL, updated_at); + NULL, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_image_import_task( @@ -1533,7 +1708,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_image_import_task( } return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, source_path, scanset_id, NULL, NULL, NULL, NULL, NULL, NULL, - updated_at); + NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_feature_extract_task( @@ -1544,7 +1719,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_feature_extract_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, NULL, 0, - parameters, NULL, NULL, NULL, NULL, NULL, updated_at); + parameters, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_record_sift_extract_task( @@ -1554,7 +1729,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_sift_extract_task( const Lardon3DProjectDbSiftExtractTask *parameters, int64_t updated_at) { if (!parameters) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; return record_task_internal(database, snapshot, task_kind, task_kind_version, checkpoint, NULL, 0, - NULL, parameters, NULL, NULL, NULL, NULL, updated_at); + NULL, parameters, NULL, NULL, NULL, NULL, NULL, updated_at); } static bool read_task(sqlite3_stmt *statement, Lardon3DProjectDbTask *task) { @@ -3536,7 +3711,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_visual_index_update_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, parameters, NULL, NULL, NULL, updated_at); + NULL, parameters, NULL, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_visual_index_update_task( @@ -3583,7 +3758,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_candidate_pair_generate_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, NULL, parameters, NULL, NULL, updated_at); + NULL, NULL, parameters, NULL, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_candidate_pair_generate_task( @@ -3639,7 +3814,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_matcher_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, NULL, NULL, parameters, NULL, updated_at); + NULL, NULL, NULL, parameters, NULL, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_matcher_task( @@ -3697,7 +3872,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_geometric_verifier_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } return record_task_internal(db, snapshot, kind, version, checkpoint, NULL, 0, NULL, - NULL, NULL, NULL, NULL, parameters, updated_at); + NULL, NULL, NULL, NULL, parameters, NULL, updated_at); } Lardon3DProjectDbResult lardon3d_project_db_load_geometric_verifier_task( @@ -3769,7 +3944,7 @@ Lardon3DProjectDbResult lardon3d_project_db_record_track_builder_task( return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; } Lardon3DProjectDbResult result = record_task_internal( - db, snapshot, kind, version, checkpoint, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, + db, snapshot, kind, version, checkpoint, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, updated_at); if (result != LARDON3D_PROJECT_DB_OK) return result; (void)pthread_mutex_lock(&db->mutex); @@ -3875,6 +4050,123 @@ Lardon3DProjectDbResult lardon3d_project_db_load_track_builder_task( return result; } +Lardon3DProjectDbResult lardon3d_project_db_record_sparse_sfm_task( + Lardon3DProjectDb *database, const Lardon3DTaskDurableSnapshot *snapshot, + const char *kind, uint32_t version, const Lardon3DProjectDbCheckpoint *checkpoint, + const Lardon3DProjectDbSparseSfmTask *parameters, int64_t updated_at) { + if (!parameters) return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + return record_task_internal(database, snapshot, kind, version, checkpoint, NULL, 0, NULL, NULL, + NULL, NULL, NULL, NULL, parameters, updated_at); +} + +static bool read_u64_blob(sqlite3_stmt *statement, int column, uint64_t *value) { + if (sqlite3_column_type(statement, column) != SQLITE_BLOB || + sqlite3_column_bytes(statement, column) != 8) { + return false; + } + const unsigned char *bytes = sqlite3_column_blob(statement, column); + uint64_t decoded = 0; + for (unsigned int i = 0; i < 8; ++i) decoded |= (uint64_t)bytes[i] << (8U * i); + *value = decoded; + return true; +} + +Lardon3DProjectDbResult lardon3d_project_db_load_sparse_sfm_task( + Lardon3DProjectDb *database, uint64_t task_id, + Lardon3DProjectDbSparseSfmTask *parameters) { + if (!database || !valid_task_id(task_id) || !parameters) { + return LARDON3D_PROJECT_DB_INVALID_ARGUMENT; + } + memset(parameters, 0, sizeof(*parameters)); + (void)pthread_mutex_lock(&database->mutex); + sqlite3_stmt *statement = NULL; + Lardon3DProjectDbResult result = prepare( + database, + "SELECT s.track_set_id,s.calibration_scope_id,s.sfm_kind,s.sfm_version," + "s.minimum_seed_tracks,s.minimum_seed_landmarks,s.minimum_pnp_correspondences," + "s.maximum_seed_candidates,s.maximum_registration_rounds,s.maximum_landmarks_per_round," + "s.maximum_images,s.maximum_observations,s.maximum_tracks,s.reprojection_threshold_px," + "s.minimum_track_parallax_rad,s.relative_robust_threshold_px,s.relative_confidence," + "s.relative_max_iterations,s.relative_minimum_inliers,s.relative_minimum_inlier_ratio," + "s.relative_minimum_parallax_rad,s.relative_minimum_cheirality_ratio," + "s.relative_deterministic_seed,s.pnp_reprojection_threshold_px,s.pnp_confidence," + "s.pnp_max_iterations,s.pnp_minimum_inliers,s.pnp_minimum_inlier_ratio," + "s.pnp_deterministic_seed,s.refinement_max_iterations,s.refinement_convergence_tolerance," + "t.task_kind,t.task_kind_version FROM sparse_sfm_tasks s JOIN tasks t ON t.task_id=s.task_id " + "WHERE s.task_id=?1", + &statement); + if (result == LARDON3D_PROJECT_DB_OK) { + sqlite3_bind_int64(statement, 1, (sqlite3_int64)task_id); + int code = sqlite3_step(statement); + bool corrupt = code != SQLITE_ROW; + if (code == SQLITE_DONE) { + result = LARDON3D_PROJECT_DB_NOT_FOUND; + } else if (!corrupt) { + const char *kind = (const char *)sqlite3_column_text(statement, 31); + sqlite3_int64 ids[2] = {sqlite3_column_int64(statement, 0), + sqlite3_column_int64(statement, 1)}; + sqlite3_int64 integers[15]; + const int integer_columns[15] = {2, 3, 4, 5, 6, 7, 8, 9, 10, 17, 18, 25, 26, 29, 32}; + for (size_t i = 0; i < 15; ++i) integers[i] = sqlite3_column_int64(statement, + integer_columns[i]); + double doubles[11]; + const int double_columns[11] = {13, 14, 15, 16, 19, 20, 21, 23, 24, 27, 30}; + for (size_t i = 0; i < 11; ++i) doubles[i] = sqlite3_column_double(statement, + double_columns[i]); + corrupt = ids[0] <= 0 || ids[1] <= 0 || !kind || strcmp(kind, "sparse_sfm.run") != 0 || + integers[0] != 1 || integers[1] != 1 || integers[14] != 1; + for (size_t i = 2; i < 14; ++i) { + if (integers[i] <= 0 || integers[i] > UINT32_MAX) corrupt = true; + } + for (size_t i = 0; i < 11; ++i) { + if (sqlite3_column_type(statement, double_columns[i]) != SQLITE_FLOAT || + !isfinite(doubles[i])) { + corrupt = true; + } + } + parameters->task_id = task_id; + parameters->track_set_id = (uint64_t)ids[0]; + parameters->calibration_scope_id = (uint64_t)ids[1]; + parameters->sfm_kind = (uint32_t)integers[0]; + parameters->sfm_version = (uint32_t)integers[1]; + Lardon3DSparseIncrementalParameters *p = ¶meters->parameters; + p->minimum_seed_tracks = (uint32_t)integers[2]; + p->minimum_seed_landmarks = (uint32_t)integers[3]; + p->minimum_pnp_correspondences = (uint32_t)integers[4]; + p->maximum_seed_candidates = (uint32_t)integers[5]; + p->maximum_registration_rounds = (uint32_t)integers[6]; + p->maximum_landmarks_per_round = (uint32_t)integers[7]; + p->maximum_images = (uint32_t)integers[8]; + corrupt |= !read_u64_blob(statement, 11, &p->maximum_observations); + corrupt |= !read_u64_blob(statement, 12, &p->maximum_tracks); + p->reprojection_threshold_px = doubles[0]; + p->minimum_track_parallax_rad = doubles[1]; + p->relative_pose.robust_threshold_px = doubles[2]; + p->relative_pose.confidence = doubles[3]; + p->relative_pose.max_iterations = (uint32_t)integers[9]; + p->relative_pose.minimum_inliers = (uint32_t)integers[10]; + p->relative_pose.minimum_inlier_ratio = doubles[4]; + p->relative_pose.minimum_parallax_rad = doubles[5]; + p->relative_pose.minimum_cheirality_ratio = doubles[6]; + corrupt |= !read_u64_blob(statement, 22, &p->relative_pose.deterministic_seed); + p->pnp.reprojection_threshold_px = doubles[7]; + p->pnp.confidence = doubles[8]; + p->pnp.max_iterations = (uint32_t)integers[11]; + p->pnp.minimum_inliers = (uint32_t)integers[12]; + p->pnp.minimum_inlier_ratio = doubles[9]; + corrupt |= !read_u64_blob(statement, 28, &p->pnp.deterministic_seed); + p->refinement.max_iterations = (uint32_t)integers[13]; + p->refinement.convergence_tolerance = doubles[10]; + if (corrupt || !valid_sparse_sfm_parameters(p)) result = LARDON3D_PROJECT_DB_CORRUPT; + } else { + result = sqlite_result(database, code, "read sparse SfM task"); + } + sqlite3_finalize(statement); + } + (void)pthread_mutex_unlock(&database->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/sparse_sfm_gate_f.cpp b/src/sparse_sfm_gate_f.cpp new file mode 100644 index 0000000..ad0a36b --- /dev/null +++ b/src/sparse_sfm_gate_f.cpp @@ -0,0 +1,152 @@ +#include "sparse_sfm_gate_f_internal.h" + +#include +#include +#include +#include +#include +#include + +namespace { +bool put_u32(unsigned char *record, size_t *offset, uint32_t value) { + if (*offset > 372U - 4U) return false; + for (unsigned int byte = 0; byte < 4; ++byte) + record[(*offset)++] = static_cast(value >> (byte * 8U)); + return true; +} + +bool put_u64(unsigned char *record, size_t *offset, uint64_t value) { + if (*offset > 372U - 8U) return false; + for (unsigned int byte = 0; byte < 8; ++byte) + record[(*offset)++] = static_cast(value >> (byte * 8U)); + return true; +} + +bool put_f64(unsigned char *record, size_t *offset, double value) { + if (!std::isfinite(value)) return false; + if (value == 0.0) value = 0.0; + uint64_t bits = 0; + static_assert(sizeof(bits) == sizeof(value)); + std::memcpy(&bits, &value, sizeof(bits)); + return put_u64(record, offset, bits); +} + +bool checked_add(uint64_t left, uint64_t right, uint64_t *result) { + if (right > std::numeric_limits::max() - left) return false; + *result = left + right; + return true; +} + +bool checked_mul(uint64_t left, uint64_t right, uint64_t *result) { + if (left != 0 && right > std::numeric_limits::max() / left) return false; + *result = left * right; + return true; +} +} // namespace + +bool lardon3d_sparse_sfm_fingerprint_record( + const Lardon3DSparseIncrementalParameters *p, unsigned char record[372]) { + if (!p || !record) return false; + std::memset(record, 0, 372); + std::memcpy(record, "L3DSFMFP", 8); + size_t offset = 8; + bool ok = put_u32(record, &offset, 1) && + put_u32(record, &offset, p->minimum_seed_tracks) && + put_u32(record, &offset, p->minimum_seed_landmarks) && + put_u32(record, &offset, p->minimum_pnp_correspondences) && + put_u32(record, &offset, p->maximum_seed_candidates) && + put_u32(record, &offset, p->maximum_registration_rounds) && + put_u32(record, &offset, p->maximum_landmarks_per_round) && + put_u32(record, &offset, p->maximum_images) && + put_u64(record, &offset, p->maximum_observations) && + put_u64(record, &offset, p->maximum_tracks) && + put_f64(record, &offset, p->reprojection_threshold_px) && + put_f64(record, &offset, p->minimum_track_parallax_rad) && + put_f64(record, &offset, p->relative_pose.robust_threshold_px) && + put_f64(record, &offset, p->relative_pose.confidence) && + put_u32(record, &offset, p->relative_pose.max_iterations) && + put_u32(record, &offset, p->relative_pose.minimum_inliers) && + put_f64(record, &offset, p->relative_pose.minimum_inlier_ratio) && + put_f64(record, &offset, p->relative_pose.minimum_parallax_rad) && + put_f64(record, &offset, p->relative_pose.minimum_cheirality_ratio) && + put_u64(record, &offset, p->relative_pose.deterministic_seed) && + put_f64(record, &offset, p->pnp.reprojection_threshold_px) && + put_f64(record, &offset, p->pnp.confidence) && + put_u32(record, &offset, p->pnp.max_iterations) && + put_u32(record, &offset, p->pnp.minimum_inliers) && + put_f64(record, &offset, p->pnp.minimum_inlier_ratio) && + put_u64(record, &offset, p->pnp.deterministic_seed) && + put_u32(record, &offset, p->refinement.max_iterations) && + put_f64(record, &offset, p->refinement.convergence_tolerance); + for (unsigned int policy = 0; ok && policy < 19; ++policy) + ok = put_u32(record, &offset, 1); + ok = ok && put_f64(record, &offset, 1e-9); + for (unsigned int policy = 0; ok && policy < 2; ++policy) + ok = put_u32(record, &offset, 1); + ok = ok && put_f64(record, &offset, 1e-9) && put_u32(record, &offset, 1) && + put_f64(record, &offset, 2.0); + for (unsigned int policy = 0; ok && policy < 6; ++policy) + ok = put_u32(record, &offset, 1); + ok = ok && put_u32(record, &offset, 1) && put_u32(record, &offset, 50) && + put_f64(record, &offset, 1e-6) && put_f64(record, &offset, 1e-10) && + put_f64(record, &offset, 1e-8) && put_u32(record, &offset, 0) && + put_u32(record, &offset, 1) && put_f64(record, &offset, 1e-12) && + put_u32(record, &offset, 1) && put_u32(record, &offset, 1); + return ok && offset == 372; +} + +bool lardon3d_sparse_sfm_parameter_fingerprint( + const Lardon3DSparseIncrementalParameters *parameters, unsigned char digest[32]) { + if (!digest) return false; + unsigned char record[372]; + unsigned int length = 0; + return lardon3d_sparse_sfm_fingerprint_record(parameters, record) && + EVP_Digest(record, sizeof(record), digest, &length, EVP_sha256(), nullptr) == 1 && + length == 32; +} + +bool lardon3d_sparse_sfm_resource_estimate(uint64_t image_count, uint64_t track_count, + uint64_t observation_count, + Lardon3DResourceEstimate *estimate) { + if (!estimate) return false; + uint64_t images = 0, tracks = 0, observations = 0, raw = 134217728ULL; + if (!checked_mul(image_count, 65536ULL, &images) || + !checked_mul(track_count, 2048ULL, &tracks) || + !checked_mul(observation_count, 512ULL, &observations) || + !checked_add(raw, images, &raw) || !checked_add(raw, tracks, &raw) || + !checked_add(raw, observations, &raw)) return false; + constexpr uint64_t mib = 1024ULL * 1024ULL; + uint64_t remainder = raw % mib; + if (remainder != 0 && !checked_add(raw, mib - remainder, &raw)) return false; + *estimate = {raw, 0, 0, 0, 1, 1, 1, 0, 1, LARDON3D_RESOURCE_TASK_CPU}; + return true; +} + +bool lardon3d_sparse_sfm_component_persistable(uint64_t registered_image_count, + uint64_t landmark_count) { + return registered_image_count > 0 && landmark_count > 0; +} + +bool lardon3d_sparse_sfm_publication_metrics(const double *squared_errors, size_t count, + double *rmse, double *median) { + if (!squared_errors || count == 0 || !rmse || !median) return false; + try { + std::vector errors; + errors.reserve(count); + double sum = 0.0; + for (size_t index = 0; index < count; ++index) { + double squared = squared_errors[index]; + if (!std::isfinite(squared) || squared < 0.0 || !std::isfinite(sum + squared)) return false; + sum += squared; + errors.push_back(std::sqrt(squared)); + } + std::sort(errors.begin(), errors.end()); + *rmse = std::sqrt(sum / static_cast(count)); + size_t middle = count / 2; + *median = count % 2 ? errors[middle] + : errors[middle - 1] + (errors[middle] - errors[middle - 1]) / 2.0; + return std::isfinite(*rmse) && std::isfinite(*median); + } catch (...) { + return false; + } +} diff --git a/src/sparse_sfm_gate_f_internal.h b/src/sparse_sfm_gate_f_internal.h new file mode 100644 index 0000000..5d10835 --- /dev/null +++ b/src/sparse_sfm_gate_f_internal.h @@ -0,0 +1,23 @@ +#ifndef LARDON3D_SPARSE_SFM_GATE_F_INTERNAL_H +#define LARDON3D_SPARSE_SFM_GATE_F_INTERNAL_H + +#include +#include +#include + +#include +#include + +bool lardon3d_sparse_sfm_fingerprint_record( + const Lardon3DSparseIncrementalParameters *parameters, unsigned char record[372]); +bool lardon3d_sparse_sfm_parameter_fingerprint( + const Lardon3DSparseIncrementalParameters *parameters, unsigned char digest[32]); +bool lardon3d_sparse_sfm_resource_estimate(uint64_t participating_image_count, + uint64_t track_count, uint64_t observation_count, + Lardon3DResourceEstimate *estimate); +bool lardon3d_sparse_sfm_component_persistable(uint64_t registered_image_count, + uint64_t landmark_count); +bool lardon3d_sparse_sfm_publication_metrics(const double *squared_errors, size_t count, + double *rmse, double *median); + +#endif diff --git a/src/sparse_sfm_task.cpp b/src/sparse_sfm_task.cpp new file mode 100644 index 0000000..e5225b2 --- /dev/null +++ b/src/sparse_sfm_task.cpp @@ -0,0 +1,667 @@ +#include "sparse_sfm_gate_f_internal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +} + +namespace { +struct Context { + std::string project_path; + Lardon3DProjectDb *database = nullptr; + Lardon3DProjectDbSparseSfmTask durable{}; + struct WorkloadShape { + uint64_t image_count = 0; + uint64_t track_count = 0; + uint64_t observation_count = 0; + } shape; +}; + +struct ObservationKey { + uint64_t feature_set_id; + uint32_t feature_index; + + bool operator<(const ObservationKey &other) const { + return std::tie(feature_set_id, feature_index) < + std::tie(other.feature_set_id, other.feature_index); + } +}; + +struct Reader { + Lardon3DProjectDbFeatureSet feature_set{}; + Lardon3DFeatureReader *reader = nullptr; + Lardon3DFeatureFileMetadata metadata{}; +}; + +struct Resolved { + std::vector images; + std::vector observations; + std::map calibrations; + std::map observation_index; +}; + +struct PublicationStorage { + std::vector components; + std::vector cameras; + std::vector landmarks; + std::vector observations; + double rmse = 0.0; + double median = 0.0; +}; + +void destroy_context(void *userdata) { delete static_cast(userdata); } + +bool checked_add(uint64_t left, uint64_t right, uint64_t *output) { + if (right > UINT64_MAX - left) return false; + *output = left + right; + return true; +} + +bool load_calibration_membership(const Context &context, std::set *images) { + Lardon3DSparseCalibrationScope scope{}; + if (lardon3d_sparse_calibration_scope_load(context.database, + context.durable.calibration_scope_id, &scope) != + LARDON3D_PROJECT_DB_OK) + return false; + uint64_t after = 0; + for (;;) { + Lardon3DSparseCalibrationMember members[LARDON3D_SPARSE_SFM_PAGE_MAX]{}; + size_t count = 0; + uint64_t next = 0; + if (lardon3d_sparse_calibration_scope_list_members( + context.database, scope.scope_id, after, members, LARDON3D_SPARSE_SFM_PAGE_MAX, + &count, &next) != LARDON3D_PROJECT_DB_OK) + return false; + for (size_t index = 0; index < count; ++index) + if (!images->insert(members[index].image_id).second) return false; + if (count == 0) break; + if (next <= after) return false; + after = next; + } + return images->size() == scope.member_count; +} + +bool derive_workload_shape(const Context &context, Context::WorkloadShape *shape) { + Lardon3DProjectDbTrackSet track_set{}; + if (!shape || + lardon3d_project_db_load_track_set(context.database, context.durable.track_set_id, + &track_set) != LARDON3D_PROJECT_DB_OK) + return false; + std::set calibration_images; + if (!load_calibration_membership(context, &calibration_images)) return false; + std::map feature_sets; + std::set participating_images; + Context::WorkloadShape result{}; + uint64_t after = 0; + for (;;) { + Lardon3DProjectDbTrack tracks[LARDON3D_SPARSE_SFM_PAGE_MAX]{}; + size_t count = 0; + if (lardon3d_project_db_list_tracks(context.database, track_set.track_set_id, after, tracks, + LARDON3D_SPARSE_SFM_PAGE_MAX, &count) != + LARDON3D_PROJECT_DB_OK) + return false; + bool ok = true; + for (size_t index = 0; ok && index < count; ++index) { + const auto &track = tracks[index]; + if (track.track_id <= after || track.track_set_id != track_set.track_set_id || + !checked_add(result.observation_count, track.observation_count, + &result.observation_count)) { + ok = false; + break; + } + for (uint32_t position = 0; position < track.observation_count; ++position) { + const auto &observation = track.observations[position]; + auto feature = feature_sets.find(observation.feature_set_id); + if (feature == feature_sets.end()) { + Lardon3DProjectDbFeatureSet feature_set{}; + if (lardon3d_project_db_load_feature_set(context.database, + observation.feature_set_id, + &feature_set) != LARDON3D_PROJECT_DB_OK) { + ok = false; + break; + } + feature = feature_sets.emplace(observation.feature_set_id, feature_set).first; + } + if (observation.feature_index >= feature->second.feature_count || + !calibration_images.count(feature->second.image_id)) { + ok = false; + break; + } + participating_images.insert(feature->second.image_id); + } + after = track.track_id; + ++result.track_count; + } + for (size_t index = 0; index < count; ++index) + lardon3d_project_db_free_track(&tracks[index]); + if (!ok) return false; + if (count == 0) break; + } + result.image_count = participating_images.size(); + if (result.track_count != track_set.track_count || result.image_count == 0 || + result.observation_count == 0) + return false; + *shape = result; + return true; +} + +bool build_observation_index(Resolved *resolved) { + resolved->observation_index.clear(); + for (size_t index = 0; index < resolved->observations.size(); ++index) { + const auto &observation = resolved->observations[index]; + if (!resolved->observation_index + .emplace(ObservationKey{observation.feature_set_id, observation.feature_index}, index) + .second) + return false; + } + return true; +} + +void project_landmarks(const Lardon3DSparseIncrementalLandmark *landmarks, + size_t landmark_count, const std::set &component_keys, + std::vector *output) { + for (size_t index = 0; index < landmark_count; ++index) { + const auto &landmark = landmarks[index]; + if (!component_keys.count(landmark.component_key)) continue; + output->push_back( + {0, landmark.track_id, landmark.component_key, landmark.point.x, landmark.point.y, + landmark.point.z, landmark.reprojection_rmse_px, landmark.reprojection_median_px, + landmark.observation_count}); + } + std::sort(output->begin(), output->end(), [](const auto &a, const auto &b) { + return std::tie(a.component_key, a.track_id) < + std::tie(b.component_key, b.track_id); + }); +} + +void close_readers(std::map *readers) { + for (auto &item : *readers) + if (item.second.reader) lardon3d_feature_reader_close(item.second.reader); + readers->clear(); +} + +bool load_reader(const Context &context, uint64_t feature_set_id, + std::map *readers, Reader **output) { + auto existing = readers->find(feature_set_id); + if (existing != readers->end()) { + *output = &existing->second; + return true; + } + Reader candidate; + if (lardon3d_project_db_load_feature_set(context.database, feature_set_id, + &candidate.feature_set) != LARDON3D_PROJECT_DB_OK || + lardon3d_feature_reader_open(context.project_path.c_str(), &candidate.feature_set, + &candidate.reader, &candidate.metadata) != + LARDON3D_FEATURE_STORE_OK || + candidate.metadata.feature_count != candidate.feature_set.feature_count) { + if (candidate.reader) lardon3d_feature_reader_close(candidate.reader); + return false; + } + auto inserted = readers->emplace(feature_set_id, candidate); + *output = &inserted.first->second; + return true; +} + +bool load_calibrations(const Context &context, Resolved *resolved) { + Lardon3DSparseCalibrationScope scope{}; + if (lardon3d_sparse_calibration_scope_load(context.database, + context.durable.calibration_scope_id, &scope) != + LARDON3D_PROJECT_DB_OK) return false; + uint64_t after = 0; + for (;;) { + Lardon3DSparseCalibrationMember members[LARDON3D_SPARSE_SFM_PAGE_MAX]{}; + size_t count = 0; + uint64_t next = 0; + if (lardon3d_sparse_calibration_scope_list_members( + context.database, scope.scope_id, after, members, LARDON3D_SPARSE_SFM_PAGE_MAX, + &count, &next) != LARDON3D_PROJECT_DB_OK) return false; + for (size_t index = 0; index < count; ++index) { + Lardon3DSparseCalibration calibration{}; + if (lardon3d_sparse_calibration_load(context.database, members[index].calibration_id, + &calibration) != LARDON3D_PROJECT_DB_OK || + std::memcmp(calibration.scientific_hash, members[index].calibration_hash, 32) != 0) + return false; + resolved->calibrations.emplace( + members[index].image_id, + Lardon3DSparseGeometryCalibration{calibration.width, calibration.height, calibration.fx, + calibration.fy, calibration.cx, calibration.cy, + calibration.k1, calibration.k2, calibration.p1, + calibration.p2}); + } + if (count == 0) break; + if (next <= after) return false; + after = next; + } + return resolved->calibrations.size() == scope.member_count; +} + +bool resolve_input(const Context &context, Resolved *resolved, uint64_t *track_count) { + Lardon3DProjectDbTrackSet track_set{}; + if (lardon3d_project_db_load_track_set(context.database, context.durable.track_set_id, + &track_set) != LARDON3D_PROJECT_DB_OK || + !load_calibrations(context, resolved)) return false; + std::map readers; + std::set participating_images; + uint64_t after = 0; + uint64_t tracks_seen = 0; + bool ok = true; + while (ok) { + Lardon3DProjectDbTrack tracks[LARDON3D_SPARSE_SFM_PAGE_MAX]{}; + size_t count = 0; + if (lardon3d_project_db_list_tracks(context.database, track_set.track_set_id, after, tracks, + LARDON3D_SPARSE_SFM_PAGE_MAX, &count) != + LARDON3D_PROJECT_DB_OK) { + ok = false; + break; + } + for (size_t index = 0; ok && index < count; ++index) { + const Lardon3DProjectDbTrack &track = tracks[index]; + if (track.track_id <= after || track.track_set_id != track_set.track_set_id) { + ok = false; + break; + } + for (uint32_t position = 0; ok && position < track.observation_count; ++position) { + const Lardon3DProjectDbTrackObservation &observation = track.observations[position]; + Reader *reader = nullptr; + Lardon3DFeatureKeypoint keypoint{}; + if (!load_reader(context, observation.feature_set_id, &readers, &reader) || + observation.feature_index >= reader->metadata.feature_count || + lardon3d_feature_reader_keypoints(reader->reader, observation.feature_index, &keypoint, + 1) != LARDON3D_FEATURE_STORE_OK || + !std::isfinite(keypoint.x) || !std::isfinite(keypoint.y) || + resolved->calibrations.find(reader->feature_set.image_id) == + resolved->calibrations.end()) { + ok = false; + break; + } + participating_images.insert(reader->feature_set.image_id); + resolved->observations.push_back( + {track.track_id, reader->feature_set.image_id, observation.feature_set_id, + observation.feature_index, reader->metadata.feature_count, + static_cast(keypoint.x), static_cast(keypoint.y)}); + } + after = track.track_id; + ++tracks_seen; + } + for (size_t index = 0; index < count; ++index) + lardon3d_project_db_free_track(&tracks[index]); + if (!ok || count == 0) break; + } + close_readers(&readers); + if (!ok || tracks_seen != track_set.track_count) return false; + for (uint64_t image_id : participating_images) + resolved->images.push_back({image_id, resolved->calibrations.at(image_id)}); + *track_count = tracks_seen; + return !resolved->images.empty() && !resolved->observations.empty() && + build_observation_index(resolved); +} + +bool persist(Context *context, const Lardon3DTask *task) { + Lardon3DTaskDurableSnapshot snapshot{}; + if (!lardon3d_task_durable_snapshot(task, &snapshot)) return false; + Lardon3DProjectDbCheckpoint checkpoint{}; + std::snprintf(checkpoint.path, sizeof(checkpoint.path), ".lardon3d/checkpoints/%llu.chk", + static_cast(snapshot.id)); + checkpoint.format_version = LARDON3D_TASK_CHECKPOINT_VERSION; + checkpoint.updated_at = 0; + std::string path = context->project_path + "/" + checkpoint.path; + Lardon3DTaskCheckpointResult saved = lardon3d_task_checkpoint_save(path.c_str(), &snapshot); + if (saved != LARDON3D_TASK_CHECKPOINT_OK && + saved != LARDON3D_TASK_CHECKPOINT_PUBLISHED_NOT_DURABLE) return false; + checkpoint.durability = saved == LARDON3D_TASK_CHECKPOINT_OK + ? LARDON3D_DB_CHECKPOINT_DURABLE + : LARDON3D_DB_CHECKPOINT_PUBLISHED_NOT_DURABLE; + return lardon3d_project_db_record_sparse_sfm_task( + context->database, &snapshot, LARDON3D_SPARSE_SFM_TASK_KIND, + LARDON3D_SPARSE_SFM_TASK_KIND_VERSION, &checkpoint, &context->durable, 0) == + LARDON3D_PROJECT_DB_OK; +} + +const Lardon3DSparseIncrementalObservation *find_input_observation( + const Resolved &resolved, uint64_t track_id, uint64_t image_id, uint64_t feature_set_id, + uint32_t feature_index) { + auto found = resolved.observation_index.find(ObservationKey{feature_set_id, feature_index}); + if (found == resolved.observation_index.end()) return nullptr; + const auto &item = resolved.observations[found->second]; + return item.track_id == track_id && item.image_id == image_id ? &item : nullptr; +} + +bool build_publication(const Resolved &resolved, + const Lardon3DSparseBundleAdjustmentResult &result, + PublicationStorage *storage) { + std::set component_keys; + for (size_t index = 0; index < result.component_count; ++index) { + const auto &component = result.components[index]; + if (lardon3d_sparse_sfm_component_persistable(component.registered_image_count, + component.landmark_count)) { + component_keys.insert(component.component_key); + storage->components.push_back({0, component.component_key, component.registered_image_count, + component.landmark_count}); + } + } + if (storage->components.empty()) return false; + for (size_t index = 0; index < result.camera_count; ++index) { + const auto &camera = result.cameras[index]; + if (!component_keys.count(camera.component_key)) continue; + Lardon3DSparseRegisteredImage output{}; + output.image_id = camera.image_id; + output.component_key = camera.component_key; + std::memcpy(output.rotation_cw, camera.pose_cw.rotation_cw, sizeof(output.rotation_cw)); + std::memcpy(output.translation_cw, camera.pose_cw.translation_cw, + sizeof(output.translation_cw)); + storage->cameras.push_back(output); + } + project_landmarks(result.landmarks, result.landmark_count, component_keys, + &storage->landmarks); + std::sort(storage->cameras.begin(), storage->cameras.end(), + [](const auto &a, const auto &b) { return a.image_id < b.image_id; }); + std::map cameras; + for (const auto &camera : storage->cameras) cameras.emplace(camera.image_id, &camera); + std::map landmarks; + for (const auto &landmark : storage->landmarks) landmarks.emplace(landmark.track_id, &landmark); + std::vector squared_errors; + for (size_t index = 0; index < result.observation_count; ++index) { + const auto &observation = result.observations[index]; + auto landmark = landmarks.find(observation.track_id); + if (landmark == landmarks.end()) continue; + auto camera = cameras.find(observation.image_id); + auto calibration = resolved.calibrations.find(observation.image_id); + const auto *source = find_input_observation(resolved, observation.track_id, + observation.image_id, + observation.feature_set_id, + observation.feature_index); + if (camera == cameras.end() || calibration == resolved.calibrations.end() || !source) + return false; + const auto &pose = *camera->second; + const auto &point = *landmark->second; + double xc = pose.rotation_cw[0] * point.x + pose.rotation_cw[1] * point.y + + pose.rotation_cw[2] * point.z + pose.translation_cw[0]; + double yc = pose.rotation_cw[3] * point.x + pose.rotation_cw[4] * point.y + + pose.rotation_cw[5] * point.z + pose.translation_cw[1]; + double zc = pose.rotation_cw[6] * point.x + pose.rotation_cw[7] * point.y + + pose.rotation_cw[8] * point.z + pose.translation_cw[2]; + if (!std::isfinite(xc) || !std::isfinite(yc) || !std::isfinite(zc) || zc <= 1e-9) + return false; + double xn = xc / zc, yn = yc / zc, r2 = xn * xn + yn * yn; + const auto &cal = calibration->second; + double radial = 1.0 + cal.k1 * r2 + cal.k2 * r2 * r2; + double xd = xn * radial + 2.0 * cal.p1 * xn * yn + cal.p2 * (r2 + 2.0 * xn * xn); + double yd = yn * radial + cal.p1 * (r2 + 2.0 * yn * yn) + 2.0 * cal.p2 * xn * yn; + double dx = cal.fx * xd + cal.cx - source->x; + double dy = cal.fy * yd + cal.cy - source->y; + double squared = dx * dx + dy * dy; + if (!std::isfinite(squared)) return false; + squared_errors.push_back(squared); + storage->observations.push_back( + {0, observation.track_id, observation.feature_set_id, observation.feature_index, + observation.position_in_track}); + } + if (storage->cameras.size() < 2 || storage->landmarks.empty() || squared_errors.empty()) + return false; + std::sort(storage->observations.begin(), storage->observations.end(), [](const auto &a, + const auto &b) { + return std::tie(a.track_id, a.position_in_track) < + std::tie(b.track_id, b.position_in_track); + }); + return lardon3d_sparse_sfm_publication_metrics(squared_errors.data(), squared_errors.size(), + &storage->rmse, &storage->median); +} + +bool execute(Context *context, Lardon3DTask *task, bool *reused) { + unsigned char fingerprint[32]{}; + if (!lardon3d_sparse_sfm_parameter_fingerprint(&context->durable.parameters, fingerprint)) + return false; + Lardon3DSparseReconstruction existing{}; + Lardon3DProjectDbResult lookup = lardon3d_sparse_reconstruction_find_exact( + context->database, context->durable.track_set_id, context->durable.calibration_scope_id, + context->durable.sfm_kind, context->durable.sfm_version, fingerprint, &existing); + if (lookup == LARDON3D_PROJECT_DB_OK) { + *reused = true; + return true; + } + if (lookup != LARDON3D_PROJECT_DB_NOT_FOUND || !lardon3d_task_checkpoint(task)) return false; + Resolved resolved; + uint64_t track_count = 0; + if (!resolve_input(*context, &resolved, &track_count)) return false; + if (track_count != context->shape.track_count || + resolved.observations.size() != context->shape.observation_count || + resolved.images.size() != context->shape.image_count) + return false; + Lardon3DSparseIncrementalInput input{context->durable.track_set_id, + context->durable.calibration_scope_id, + resolved.images.data(), resolved.images.size(), + resolved.observations.data(), resolved.observations.size()}; + Lardon3DSparseIncrementalResult incremental{}; + Lardon3DSparseIncrementalStatus d_status = lardon3d_sparse_incremental_run( + &input, &context->durable.parameters, &incremental); + if (d_status != LARDON3D_SPARSE_INCREMENTAL_COMPLETE && + d_status != LARDON3D_SPARSE_INCREMENTAL_PARTIAL) { + lardon3d_sparse_incremental_result_destroy(&incremental); + return false; + } + if (!lardon3d_task_checkpoint(task)) { + lardon3d_sparse_incremental_result_destroy(&incremental); + return false; + } + Lardon3DSparseBundleAdjustmentInput e_input{&incremental, resolved.images.data(), + resolved.images.size(), + resolved.observations.data(), + resolved.observations.size()}; + Lardon3DSparseBundleAdjustmentResult adjusted{}; + auto e_status = lardon3d_sparse_bundle_adjustment_run(&e_input, &adjusted); + lardon3d_sparse_incremental_result_destroy(&incremental); + if (e_status != LARDON3D_SPARSE_BUNDLE_ADJUSTMENT_EXECUTION_OK || + !lardon3d_task_checkpoint(task)) { + lardon3d_sparse_bundle_adjustment_result_destroy(&adjusted); + return false; + } + PublicationStorage storage; + bool valid = build_publication(resolved, adjusted, &storage); + if (!valid) { + lardon3d_sparse_bundle_adjustment_result_destroy(&adjusted); + return false; + } + Lardon3DSparsePublication publication{ + context->durable.track_set_id, + context->durable.calibration_scope_id, + context->durable.sfm_kind, + context->durable.sfm_version, + {}, + storage.components.data(), + storage.components.size(), + storage.cameras.data(), + storage.cameras.size(), + storage.landmarks.data(), + storage.landmarks.size(), + storage.observations.data(), + storage.observations.size(), + storage.rmse, + storage.median, + static_cast(std::time(nullptr))}; + std::memcpy(publication.parameter_fingerprint, fingerprint, 32); + Lardon3DSparseReconstruction published{}; + Lardon3DProjectDbResult publish_result = + lardon3d_sparse_reconstruction_publish(context->database, &publication, &published); + lardon3d_sparse_bundle_adjustment_result_destroy(&adjusted); + if (publish_result == LARDON3D_PROJECT_DB_OK) return true; + if (publish_result != LARDON3D_PROJECT_DB_CONSTRAINT) return false; + lookup = lardon3d_sparse_reconstruction_find_exact( + context->database, context->durable.track_set_id, context->durable.calibration_scope_id, + context->durable.sfm_kind, context->durable.sfm_version, fingerprint, &existing); + *reused = lookup == LARDON3D_PROJECT_DB_OK; + return *reused; +} + +bool run(Lardon3DTask *task, void *userdata) { + try { + bool reused = false; + if (!execute(static_cast(userdata), task, &reused)) + return lardon3d_task_fail(task, "Sparse SfM non publiable."); + return lardon3d_task_set_progress(task, 100, + reused ? "Reconstruction réutilisée." + : "Reconstruction publiée."); + } catch (const std::bad_alloc &) { + return lardon3d_task_fail(task, "Mémoire insuffisante pour Sparse SfM."); + } catch (...) { + return lardon3d_task_fail(task, "Erreur interne Sparse SfM."); + } +} + +void finished(const Lardon3DTask *task, void *userdata) { + try { + (void)persist(static_cast(userdata), task); + } catch (...) { + } +} +} // namespace + +#ifdef LARDON3D_SPARSE_SFM_TASK_TESTING +extern "C" int lardon3d_sparse_sfm_task_test_observation_lookup( + const Lardon3DSparseIncrementalObservation *observations, size_t count, + uint64_t track_id, uint64_t image_id, uint64_t feature_set_id, + uint32_t feature_index, size_t *resolved_index) { + try { + if ((!observations && count != 0) || !resolved_index) return -1; + Resolved resolved; + if (count != 0) resolved.observations.assign(observations, observations + count); + if (!build_observation_index(&resolved)) return -1; + const auto *found = find_input_observation(resolved, track_id, image_id, + feature_set_id, feature_index); + if (!found) return 0; + *resolved_index = static_cast(found - resolved.observations.data()); + return 1; + } catch (...) { + return -1; + } +} + +extern "C" bool lardon3d_sparse_sfm_task_test_project_landmarks( + const Lardon3DSparseIncrementalLandmark *landmarks, size_t landmark_count, + const uint64_t *component_keys, size_t component_count, + Lardon3DSparseLandmark *output, size_t output_capacity, size_t *output_count) { + try { + if ((!landmarks && landmark_count != 0) || + (!component_keys && component_count != 0) || !output_count || + (!output && output_capacity != 0)) + return false; + std::set persistable_components; + for (size_t index = 0; index < component_count; ++index) + if (!persistable_components.insert(component_keys[index]).second) return false; + std::vector projected; + project_landmarks(landmarks, landmark_count, persistable_components, &projected); + if (projected.size() > output_capacity || (!output && !projected.empty())) return false; + std::copy(projected.begin(), projected.end(), output); + *output_count = projected.size(); + return true; + } catch (...) { + return false; + } +} +#endif + +extern "C" bool lardon3d_sparse_sfm_task_reconstruct( + const Lardon3DTaskDurableSnapshot *snapshot, void *userdata, + Lardon3DTaskKindBinding *binding) { + try { + if (!snapshot || !userdata || !binding) return false; + auto *runtime = static_cast(userdata); + Lardon3DProjectDbSparseSfmTask durable{}; + if (lardon3d_project_db_load_sparse_sfm_task(runtime->project_db, snapshot->id, &durable) != + LARDON3D_PROJECT_DB_OK) return false; + auto *context = new (std::nothrow) Context; + if (!context) return false; + context->project_path = runtime->project_path; + context->database = runtime->project_db; + context->durable = durable; + unsigned char fingerprint[32]; + if (!lardon3d_sparse_sfm_parameter_fingerprint(&durable.parameters, fingerprint) || + !derive_workload_shape(*context, &context->shape)) { + delete context; + return false; + } + *binding = {run, context, destroy_context, finished, context}; + return true; + } catch (...) { + return false; + } +} + +extern "C" Lardon3DTask *lardon3d_project_create_sparse_sfm_task( + Lardon3DAppState *state, const Lardon3DSparseSfmTaskConfiguration *configuration, + uint64_t *task_id) { + try { + if (task_id) *task_id = 0; + if (!state || !state->project_loaded || !state->project_db || !state->resource_governor || + !configuration) + return nullptr; + Context temporary; + temporary.project_path = state->project_path; + temporary.database = state->project_db; + temporary.durable.track_set_id = configuration->track_set_id; + temporary.durable.calibration_scope_id = configuration->calibration_scope_id; + if (!derive_workload_shape(temporary, &temporary.shape)) return nullptr; + Lardon3DResourceEstimate estimate{}; + if (!lardon3d_sparse_sfm_resource_estimate( + temporary.shape.image_count, temporary.shape.track_count, + temporary.shape.observation_count, &estimate)) + return nullptr; + uint64_t id = 0; + if (lardon3d_project_db_allocate_task_id(state->project_db, &id) != LARDON3D_PROJECT_DB_OK) + return nullptr; + auto *context = new (std::nothrow) Context; + if (!context) return nullptr; + context->project_path = state->project_path; + context->database = state->project_db; + context->shape = temporary.shape; + context->durable = {id, configuration->track_set_id, configuration->calibration_scope_id, + LARDON3D_SPARSE_SFM_KIND_INCREMENTAL, LARDON3D_SPARSE_SFM_VERSION, + configuration->parameters}; + unsigned char fingerprint[32]; + if (!lardon3d_sparse_sfm_parameter_fingerprint(&context->durable.parameters, fingerprint)) { + delete context; + return nullptr; + } + Lardon3DTask *task = lardon3d_task_create_typed( + "Sparse SfM", &estimate, LARDON3D_SPARSE_SFM_TASK_KIND, + LARDON3D_SPARSE_SFM_TASK_KIND_VERSION, run, context, destroy_context); + if (!task || !lardon3d_task_assign_id(task, id) || + !lardon3d_task_set_finished_callback(task, finished, context) || !persist(context, task)) { + if (task) lardon3d_task_destroy(task); + else delete context; + return nullptr; + } + if (task_id) *task_id = id; + return task; + } catch (...) { + if (task_id) *task_id = 0; + return nullptr; + } +} + +extern "C" bool lardon3d_project_enqueue_sparse_sfm_task( + Lardon3DAppState *state, const Lardon3DSparseSfmTaskConfiguration *configuration, + uint64_t *task_id) { + Lardon3DTask *task = lardon3d_project_create_sparse_sfm_task(state, configuration, task_id); + if (!task || !lardon3d_task_queue_add(state->task_queue, task, nullptr)) { + if (task) lardon3d_task_destroy(task); + return false; + } + return true; +} diff --git a/src/task_kinds.c b/src/task_kinds.c index ac1e57d..e01abdd 100644 --- a/src/task_kinds.c +++ b/src/task_kinds.c @@ -8,6 +8,9 @@ #ifdef LARDON3D_TRACK_BUILDER_TASK_AVAILABLE #include #endif +#ifdef LARDON3D_SPARSE_SFM_TASK_AVAILABLE +#include +#endif #include const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_production(void) { @@ -58,6 +61,13 @@ const Lardon3DTaskKindRegistry *lardon3d_task_kind_registry_production(void) { .kind_version = LARDON3D_TRACK_BUILDER_TASK_KIND_VERSION, .reconstruct = lardon3d_track_builder_task_reconstruct, }, +#endif +#ifdef LARDON3D_SPARSE_SFM_TASK_AVAILABLE + { + .kind = LARDON3D_SPARSE_SFM_TASK_KIND, + .kind_version = LARDON3D_SPARSE_SFM_TASK_KIND_VERSION, + .reconstruct = lardon3d_sparse_sfm_task_reconstruct, + }, #endif }; static const Lardon3DTaskKindRegistry registry = { diff --git a/tests/test_geometric_verification.c b/tests/test_geometric_verification.c index 1644220..9a93fb8 100644 --- a/tests/test_geometric_verification.c +++ b/tests/test_geometric_verification.c @@ -172,6 +172,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 IF EXISTS sparse_sfm_tasks;" "DROP TABLE IF EXISTS sparse_landmark_observations;" "DROP TABLE IF EXISTS sparse_landmarks;" "DROP TABLE IF EXISTS sparse_registered_images;" @@ -194,7 +195,7 @@ static bool test_model_api(const char *path) { Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); Parents parents; CHECK(create_parents(database, &parents)); @@ -478,7 +479,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { 0)); CHECK(lardon3d_project_db_open(v11_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); Parents parents; CHECK(create_parents(database, &parents)); unsigned char fingerprint[32] = {0x91}; @@ -493,7 +494,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { migrated_result.geometric_verification_result_id; lardon3d_project_db_close(database); CHECK(query_integer( - v11_path, "SELECT value FROM metadata WHERE key='schema_version'", 16)); + v11_path, "SELECT value FROM metadata WHERE key='schema_version'", 17)); CHECK( query_integer(v11_path, "SELECT count(*) FROM sqlite_master WHERE type='index' AND " @@ -522,7 +523,7 @@ static bool test_migration(const char *v11_path, const char *failed_path) { 0)); CHECK(lardon3d_project_db_open(failed_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); return true; } diff --git a/tests/test_geometric_verifier_task.c b/tests/test_geometric_verifier_task.c index b2b5ec7..30d9669 100644 --- a/tests/test_geometric_verifier_task.c +++ b/tests/test_geometric_verifier_task.c @@ -332,6 +332,7 @@ static bool run_task_test(void) { CHECK(exec_sql( database_path, "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE IF EXISTS sparse_sfm_tasks;" "DROP TABLE IF EXISTS sparse_landmark_observations;" "DROP TABLE IF EXISTS sparse_landmarks;" "DROP TABLE IF EXISTS sparse_registered_images;" @@ -361,7 +362,7 @@ static bool run_task_test(void) { 0)); CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); CHECK(remove_tree(root)); return true; diff --git a/tests/test_match_result.c b/tests/test_match_result.c index 29daf59..21377cf 100644 --- a/tests/test_match_result.c +++ b/tests/test_match_result.c @@ -53,6 +53,7 @@ static bool create_v9_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE IF EXISTS sparse_sfm_tasks;" "DROP TABLE IF EXISTS sparse_landmark_observations;" "DROP TABLE IF EXISTS sparse_landmarks;" "DROP TABLE IF EXISTS sparse_registered_images;" @@ -116,7 +117,7 @@ static bool run_test(void) { Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(database && lardon3d_project_db_schema_version(database) == 16); + CHECK(database && lardon3d_project_db_schema_version(database) == 17); Lardon3DProjectDbScanSet scanset; CHECK(lardon3d_project_db_create_scanset(database, "Match-test", &scanset) == @@ -493,7 +494,7 @@ static bool run_test(void) { CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); /* Verify persistence: load previously created results */ CHECK(lardon3d_project_db_load_match_result(database, first_id, &loaded) == @@ -536,11 +537,11 @@ static bool run_test(void) { v9_path, "SELECT value FROM metadata WHERE key='schema_version'", 9)); CHECK(lardon3d_project_db_open(v9_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v9_path, "SELECT value FROM metadata WHERE key='schema_version'", 16)); + v9_path, "SELECT value FROM metadata WHERE key='schema_version'", 17)); CHECK( query_integer(v9_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -568,7 +569,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v10_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; diff --git a/tests/test_matcher_task.c b/tests/test_matcher_task.c index 551ed28..030e15e 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 IF EXISTS sparse_sfm_tasks;" "DROP TABLE IF EXISTS sparse_landmark_observations;" "DROP TABLE IF EXISTS sparse_landmarks;" "DROP TABLE IF EXISTS sparse_registered_images;" @@ -365,7 +366,7 @@ static bool run_test(void) { "name='matcher_tasks'", 0)); CHECK(reopen_runtime(&fixture)); - CHECK(lardon3d_project_db_schema_version(fixture.state.project_db) == 16); + CHECK(lardon3d_project_db_schema_version(fixture.state.project_db) == 17); CHECK( query_integer(database_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " diff --git a/tests/test_project_db.c b/tests/test_project_db.c index 66dcbe8..54dd1cf 100644 --- a/tests/test_project_db.c +++ b/tests/test_project_db.c @@ -116,7 +116,7 @@ static bool create_future_database(const char *path) { sqlite3_exec( connection, "CREATE TABLE metadata(key TEXT PRIMARY KEY,value INTEGER NOT NULL);" - "INSERT INTO metadata VALUES('schema_version',17);", + "INSERT INTO metadata VALUES('schema_version',18);", NULL, NULL, NULL) == SQLITE_OK; return sqlite3_close(connection) == SQLITE_OK && ok; } @@ -133,6 +133,7 @@ static bool create_v7_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE sparse_sfm_tasks;" "DROP TABLE sparse_landmark_observations;" "DROP TABLE sparse_landmarks;" "DROP TABLE sparse_registered_images;" @@ -169,6 +170,7 @@ static bool create_v6_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE sparse_sfm_tasks;" "DROP TABLE sparse_landmark_observations;" "DROP TABLE sparse_landmarks;" "DROP TABLE sparse_registered_images;" @@ -213,6 +215,7 @@ static bool create_v10_database(const char *path) { } static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE sparse_sfm_tasks;" "DROP TABLE sparse_landmark_observations;" "DROP TABLE sparse_landmarks;" "DROP TABLE sparse_registered_images;" @@ -246,6 +249,7 @@ static bool create_v13_database(const char *path) { return false; static const char sql[] = "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "DROP TABLE sparse_sfm_tasks;" "DROP TABLE sparse_landmark_observations;" "DROP TABLE sparse_landmarks;" "DROP TABLE sparse_registered_images;" @@ -914,7 +918,7 @@ static bool run_test(void) { Lardon3DProjectDb *database = NULL; CHECK(lardon3d_project_db_open(database_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(database && lardon3d_project_db_schema_version(database) == 16); + CHECK(database && lardon3d_project_db_schema_version(database) == 17); bool legacy_pending = true; CHECK(lardon3d_project_db_legacy_catalog_pending(database, &legacy_pending) == LARDON3D_PROJECT_DB_OK && @@ -1232,7 +1236,7 @@ static bool run_test(void) { database = NULL; CHECK(query_integer(database_path, "SELECT value FROM metadata WHERE key='schema_version'", - 16)); + 17)); CHECK(query_integer(database_path, "SELECT count(*) FROM tasks WHERE task_id=1", 1)); CHECK(lardon3d_project_db_open(database_path, &database, error) == @@ -1260,7 +1264,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) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); CHECK(lardon3d_project_db_get_project(database, &loaded_project) == LARDON3D_PROJECT_DB_OK && strcmp(loaded_project.stable_id, "legacy-project") == 0); @@ -1275,7 +1279,7 @@ static bool run_test(void) { database = NULL; CHECK(query_integer(legacy_path, "SELECT value FROM metadata WHERE key='schema_version'", - 16)); + 17)); CHECK(create_v1_database(failed_migration_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V2", "1", 1) == 0); @@ -1306,7 +1310,7 @@ static bool run_test(void) { lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v2_path, "SELECT value FROM metadata WHERE key='schema_version'", 16)); + v2_path, "SELECT value FROM metadata WHERE key='schema_version'", 17)); CHECK(create_v2_database(failed_v3_migration_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V3", "1", 1) == 0); @@ -1340,7 +1344,7 @@ static bool run_test(void) { lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v3_path, "SELECT value FROM metadata WHERE key='schema_version'", 16)); + v3_path, "SELECT value FROM metadata WHERE key='schema_version'", 17)); CHECK(create_v3_database(failed_v4_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V4", "1", 1) == 0); @@ -1362,7 +1366,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) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); CHECK(lardon3d_project_db_load_task(database, 9, &task) == LARDON3D_PROJECT_DB_OK); CHECK(lardon3d_project_db_load_artifact(database, "legacy-artifact", @@ -1417,7 +1421,7 @@ static bool run_test(void) { error); } CHECK(retry_v7 == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; @@ -1427,7 +1431,7 @@ static bool run_test(void) { 5)); CHECK(lardon3d_project_db_open(direct_v5_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; @@ -1436,11 +1440,11 @@ static bool run_test(void) { v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 7)); CHECK(lardon3d_project_db_open(v8_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 16)); + v8_path, "SELECT value FROM metadata WHERE key='schema_version'", 17)); CHECK(create_v7_database(failed_v8_path)); CHECK(setenv("LARDON3D_TEST_PROJECT_DB_FAIL_MIGRATION_V8", "1", 1) == 0); @@ -1457,7 +1461,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v8_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; @@ -1476,11 +1480,11 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(v10_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v10_path, "SELECT value FROM metadata WHERE key='schema_version'", 16)); + v10_path, "SELECT value FROM metadata WHERE key='schema_version'", 17)); CHECK( query_integer(v10_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -1507,7 +1511,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) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; @@ -1521,11 +1525,11 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(v13_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; CHECK(query_integer( - v13_path, "SELECT value FROM metadata WHERE key='schema_version'", 16)); + v13_path, "SELECT value FROM metadata WHERE key='schema_version'", 17)); CHECK( query_integer(v13_path, "SELECT count(*) FROM sqlite_master WHERE type='table' AND " @@ -1548,7 +1552,7 @@ static bool run_test(void) { true_v14_path); } CHECK(true_v14_result == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; CHECK( @@ -1663,7 +1667,7 @@ static bool run_test(void) { if (true_v15_result != LARDON3D_PROJECT_DB_OK) fprintf(stderr, "true v15 upgrade: %d %s\n", true_v15_result, error); CHECK(true_v15_result == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); Lardon3DProjectDbProject migrated_project; CHECK(lardon3d_project_db_get_project(database, &migrated_project) == LARDON3D_PROJECT_DB_OK && @@ -1695,7 +1699,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) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; CHECK(schema_compare(database_path, true_v15_path, true)); @@ -1730,7 +1734,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(true_v15_path, &database, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(database) == 16); + CHECK(lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; @@ -1749,7 +1753,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v14_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; @@ -1768,7 +1772,7 @@ static bool run_test(void) { 0)); CHECK(lardon3d_project_db_open(failed_v15_path, &database, error) == LARDON3D_PROJECT_DB_OK && - lardon3d_project_db_schema_version(database) == 16); + lardon3d_project_db_schema_version(database) == 17); lardon3d_project_db_close(database); database = NULL; diff --git a/tests/test_sparse_sfm_gate_f.cpp b/tests/test_sparse_sfm_gate_f.cpp new file mode 100644 index 0000000..992bbaa --- /dev/null +++ b/tests/test_sparse_sfm_gate_f.cpp @@ -0,0 +1,153 @@ +#include +#include +#include +#include +#include + +#include +#include "../src/sparse_sfm_gate_f_internal.h" + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return 1; \ + } \ + } while (false) + +static bool decode_hex(const char *hex, unsigned char *output, size_t size) { + if (std::strlen(hex) != size * 2) return false; + for (size_t i = 0; i < size; ++i) { + unsigned int value = 0; + if (std::sscanf(hex + i * 2, "%2x", &value) != 1) return false; + output[i] = static_cast(value); + } + return true; +} + +static bool hash(const unsigned char *bytes, size_t size, unsigned char digest[32]) { + unsigned int length = 0; + return EVP_Digest(bytes, size, digest, &length, EVP_sha256(), nullptr) == 1 && length == 32; +} + +int main() { + static const char golden_record_hex[] = + "4c334453464d46500100000006000000060000000600000020000000200000000010000000100000" + "40420f000000000090d003000000000000000000000000402d431cebe2361a3f000000000000f83f" + "2b8716d9cef7ef3fdc05000006000000000000000000e03f2d431cebe2361a3f000000000000e03f" + "0000000000000000000000000000f83f2b8716d9cef7ef3fe803000006000000000000000000e03f" + "00000000000000001e00000011ea2d819997713d0100000001000000010000000100000001000000" + "01000000010000000100000001000000010000000100000001000000010000000100000001000000" + "0100000001000000010000000100000095d626e80b2e113e010000000100000095d626e80b2e113e" + "01000000000000000000004001000000010000000100000001000000010000000100000001000000" + "320000008dedb5a0f7c6b03ebbbdd7d9df7cdb3d3a8c30e28e79453e000000000100000011ea2d81" + "9997713d0100000001000000"; + static const char golden_digest_hex[] = + "e1c83e5b2036e49254a9426ddbace42b7831373bc896f27abdd2f61e302f9e8c"; + unsigned char expected[372]{}; + unsigned char expected_digest[32]{}; + CHECK(decode_hex(golden_record_hex, expected, sizeof(expected))); + CHECK(decode_hex(golden_digest_hex, expected_digest, sizeof(expected_digest))); + Lardon3DSparseIncrementalParameters parameters{}; + CHECK(lardon3d_sparse_incremental_parameters_default(¶meters)); + unsigned char record[372]{}; + unsigned char digest[32]{}; + CHECK(lardon3d_sparse_sfm_fingerprint_record(¶meters, record)); + CHECK(std::memcmp(record, expected, sizeof(record)) == 0); + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(¶meters, digest)); + CHECK(std::memcmp(digest, expected_digest, sizeof(digest)) == 0); + unsigned char repeated[32]{}; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(¶meters, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) == 0); + + Lardon3DSparseIncrementalParameters mutated = parameters; + mutated.minimum_seed_tracks++; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + mutated = parameters; + mutated.maximum_tracks++; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + mutated = parameters; + mutated.reprojection_threshold_px += 0.25; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + mutated = parameters; + mutated.relative_pose.confidence -= 0.01; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + mutated = parameters; + mutated.relative_pose.deterministic_seed++; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + mutated = parameters; + mutated.pnp.confidence -= 0.01; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + mutated = parameters; + mutated.pnp.deterministic_seed++; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + mutated = parameters; + mutated.refinement.convergence_tolerance *= 2.0; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + + unsigned char policy_record[372]; + std::memcpy(policy_record, record, sizeof(record)); + policy_record[180] ^= 1; + CHECK(hash(policy_record, sizeof(policy_record), repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + std::memcpy(policy_record, record, sizeof(record)); + policy_record[256] ^= 1; + CHECK(hash(policy_record, sizeof(policy_record), repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + std::memcpy(policy_record, record, sizeof(record)); + policy_record[300] ^= 1; + CHECK(hash(policy_record, sizeof(policy_record), repeated)); + CHECK(std::memcmp(digest, repeated, sizeof(digest)) != 0); + + mutated = parameters; + mutated.minimum_track_parallax_rad = 0.0; + CHECK(lardon3d_sparse_sfm_fingerprint_record(&mutated, record)); + mutated.minimum_track_parallax_rad = -0.0; + CHECK(lardon3d_sparse_sfm_fingerprint_record(&mutated, policy_record)); + CHECK(std::memcmp(record, policy_record, sizeof(record)) == 0); + mutated = parameters; + mutated.pnp.confidence = std::numeric_limits::quiet_NaN(); + CHECK(!lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + mutated.pnp.confidence = std::numeric_limits::infinity(); + CHECK(!lardon3d_sparse_sfm_parameter_fingerprint(&mutated, repeated)); + + Lardon3DResourceEstimate estimate{}; + CHECK(lardon3d_sparse_sfm_resource_estimate(10, 100, 1000, &estimate)); + CHECK(estimate.memory_fixed_bytes == 136314880ULL); + CHECK(estimate.memory_bytes_per_item == 0 && estimate.gpu_memory_fixed_bytes == 0 && + estimate.gpu_memory_bytes_per_item == 0 && estimate.minimum_batch_size == 1 && + estimate.maximum_batch_size == 1 && estimate.desired_cpu_threads == 1 && + estimate.desired_gpu_slots == 0 && estimate.desired_io_slots == 1 && + estimate.task_class == LARDON3D_RESOURCE_TASK_CPU); + CHECK(!lardon3d_sparse_sfm_resource_estimate(UINT64_MAX, 1, 1, &estimate)); + CHECK(!lardon3d_sparse_sfm_resource_estimate(1, UINT64_MAX, 1, &estimate)); + CHECK(!lardon3d_sparse_sfm_resource_estimate(1, 1, UINT64_MAX, &estimate)); + CHECK(lardon3d_sparse_sfm_component_persistable(2, 3)); + CHECK(!lardon3d_sparse_sfm_component_persistable(0, 3)); + CHECK(!lardon3d_sparse_sfm_component_persistable(2, 0)); + double rmse = 0.0; + double median = 0.0; + const double zero[] = {0.0}; + CHECK(lardon3d_sparse_sfm_publication_metrics(zero, 1, &rmse, &median)); + CHECK(rmse == 0.0 && median == 0.0); + const double odd[] = {1.0, 9.0, 4.0}; + CHECK(lardon3d_sparse_sfm_publication_metrics(odd, 3, &rmse, &median)); + CHECK(std::abs(rmse - std::sqrt(14.0 / 3.0)) < 1e-15 && median == 2.0); + const double even[] = {1.0, 16.0, 4.0, 9.0}; + CHECK(lardon3d_sparse_sfm_publication_metrics(even, 4, &rmse, &median)); + CHECK(std::abs(rmse - std::sqrt(7.5)) < 1e-15 && median == 2.5); + const double weighted[] = {1.0, 1.0, 100.0}; + CHECK(lardon3d_sparse_sfm_publication_metrics(weighted, 3, &rmse, &median)); + CHECK(std::abs(rmse - std::sqrt(34.0)) < 1e-15 && median == 1.0); + const double invalid[] = {1.0, std::numeric_limits::infinity()}; + CHECK(!lardon3d_sparse_sfm_publication_metrics(invalid, 2, &rmse, &median)); + return 0; +} diff --git a/tests/test_sparse_sfm_model.c b/tests/test_sparse_sfm_model.c index ea89c02..aa43175 100644 --- a/tests/test_sparse_sfm_model.c +++ b/tests/test_sparse_sfm_model.c @@ -91,7 +91,7 @@ static bool run_test(void) { Lardon3DProjectDb *db = NULL; char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]; CHECK(lardon3d_project_db_open(path, &db, error) == LARDON3D_PROJECT_DB_OK); - CHECK(lardon3d_project_db_schema_version(db) == 16); + CHECK(lardon3d_project_db_schema_version(db) == 17); Lardon3DProjectDbScanSet scanset; CHECK(lardon3d_project_db_create_scanset(db, "Sparse model", &scanset) == diff --git a/tests/test_sparse_sfm_payload.cpp b/tests/test_sparse_sfm_payload.cpp new file mode 100644 index 0000000..203bb7c --- /dev/null +++ b/tests/test_sparse_sfm_payload.cpp @@ -0,0 +1,142 @@ +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +#define CHECK(condition) \ + do { \ + if (!(condition)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \ + return 1; \ + } \ + } while (false) + +static bool same_double(double a, double b) { + uint64_t bits_a = 0; + uint64_t bits_b = 0; + std::memcpy(&bits_a, &a, sizeof(bits_a)); + std::memcpy(&bits_b, &b, sizeof(bits_b)); + return bits_a == bits_b; +} + +static bool same_parameters(const Lardon3DSparseIncrementalParameters &a, + const Lardon3DSparseIncrementalParameters &b) { + return a.minimum_seed_tracks == b.minimum_seed_tracks && + a.minimum_seed_landmarks == b.minimum_seed_landmarks && + a.minimum_pnp_correspondences == b.minimum_pnp_correspondences && + a.maximum_seed_candidates == b.maximum_seed_candidates && + a.maximum_registration_rounds == b.maximum_registration_rounds && + a.maximum_landmarks_per_round == b.maximum_landmarks_per_round && + a.maximum_images == b.maximum_images && + a.maximum_observations == b.maximum_observations && a.maximum_tracks == b.maximum_tracks && + same_double(a.reprojection_threshold_px, b.reprojection_threshold_px) && + same_double(a.minimum_track_parallax_rad, b.minimum_track_parallax_rad) && + same_double(a.relative_pose.robust_threshold_px, b.relative_pose.robust_threshold_px) && + same_double(a.relative_pose.confidence, b.relative_pose.confidence) && + a.relative_pose.max_iterations == b.relative_pose.max_iterations && + a.relative_pose.minimum_inliers == b.relative_pose.minimum_inliers && + same_double(a.relative_pose.minimum_inlier_ratio, + b.relative_pose.minimum_inlier_ratio) && + same_double(a.relative_pose.minimum_parallax_rad, b.relative_pose.minimum_parallax_rad) && + same_double(a.relative_pose.minimum_cheirality_ratio, + b.relative_pose.minimum_cheirality_ratio) && + a.relative_pose.deterministic_seed == b.relative_pose.deterministic_seed && + same_double(a.pnp.reprojection_threshold_px, b.pnp.reprojection_threshold_px) && + same_double(a.pnp.confidence, b.pnp.confidence) && + a.pnp.max_iterations == b.pnp.max_iterations && + a.pnp.minimum_inliers == b.pnp.minimum_inliers && + same_double(a.pnp.minimum_inlier_ratio, b.pnp.minimum_inlier_ratio) && + a.pnp.deterministic_seed == b.pnp.deterministic_seed && + a.refinement.max_iterations == b.refinement.max_iterations && + same_double(a.refinement.convergence_tolerance, b.refinement.convergence_tolerance); +} + +int main() { + char directory[] = "/tmp/lardon3d-sfm-payload-XXXXXX"; + CHECK(mkdtemp(directory) != nullptr); + std::string path = std::string(directory) + "/project.db"; + Lardon3DProjectDb *database = nullptr; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{}; + CHECK(lardon3d_project_db_open(path.c_str(), &database, error) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_project_db_schema_version(database) == 17); + + sqlite3 *raw = nullptr; + CHECK(sqlite3_open(path.c_str(), &raw) == SQLITE_OK); + const char *fixture_sql = + "INSERT INTO track_sets(track_set_id,builder_kind,builder_version,parameter_fingerprint," + "verifier_kind,verifier_version,verifier_fingerprint,input_scope_hash,gvr_count,track_count," + "created_at) VALUES(41,'track_builder',1,zeroblob(32),1,1,zeroblob(32),zeroblob(32),1,0,0);" + "INSERT INTO sparse_calibration_scopes(scope_id,scientific_hash,member_count) " + "VALUES(43,randomblob(32),1);"; + CHECK(sqlite3_exec(raw, fixture_sql, nullptr, nullptr, nullptr) == SQLITE_OK); + sqlite3_close(raw); + + const uint64_t values[] = {0, 1, static_cast(INT64_MAX), + static_cast(INT64_MAX) + 1, UINT64_MAX}; + for (size_t index = 0; index < sizeof(values) / sizeof(values[0]); ++index) { + Lardon3DSparseIncrementalParameters p{}; + CHECK(lardon3d_sparse_incremental_parameters_default(&p)); + p.maximum_observations = values[index]; + p.maximum_tracks = values[index]; + p.relative_pose.deterministic_seed = values[index]; + p.pnp.deterministic_seed = values[index]; + p.reprojection_threshold_px = 3.125; + p.relative_pose.confidence = 0.9125; + p.pnp.confidence = 0.8875; + uint64_t id = 100 + index; + Lardon3DTaskDurableSnapshot snapshot{}; + snapshot.id = id; + std::snprintf(snapshot.name, sizeof(snapshot.name), "Sparse payload %zu", index); + snapshot.saved_state = TASK_PENDING; + snapshot.recovery_state = TASK_PENDING; + snapshot.estimate = {134217728, 0, 0, 0, 1, 1, 1, 0, 1, LARDON3D_RESOURCE_TASK_CPU}; + Lardon3DProjectDbSparseSfmTask payload{id, 41, 43, 1, 1, p}; + CHECK(lardon3d_project_db_record_sparse_sfm_task( + database, &snapshot, LARDON3D_SPARSE_SFM_TASK_KIND, + LARDON3D_SPARSE_SFM_TASK_KIND_VERSION, nullptr, &payload, 0) == + LARDON3D_PROJECT_DB_OK); + Lardon3DProjectDbSparseSfmTask loaded{}; + CHECK(lardon3d_project_db_load_sparse_sfm_task(database, id, &loaded) == + LARDON3D_PROJECT_DB_OK); + CHECK(loaded.task_id == id && loaded.track_set_id == 41 && + loaded.calibration_scope_id == 43 && loaded.sfm_kind == 1 && loaded.sfm_version == 1); + CHECK(same_parameters(payload.parameters, loaded.parameters)); + } + Lardon3DSparseIncrementalParameters p{}; + CHECK(lardon3d_sparse_incremental_parameters_default(&p)); + Lardon3DTaskDurableSnapshot wrong{}; + wrong.id = 999; + std::snprintf(wrong.name, sizeof(wrong.name), "Wrong kind"); + wrong.saved_state = TASK_PENDING; + wrong.recovery_state = TASK_PENDING; + wrong.estimate = {134217728, 0, 0, 0, 1, 1, 1, 0, 1, LARDON3D_RESOURCE_TASK_CPU}; + Lardon3DProjectDbSparseSfmTask payload{999, 41, 43, 1, 1, p}; + CHECK(lardon3d_project_db_record_sparse_sfm_task(database, &wrong, "other.run", 1, nullptr, + &payload, 0) == + LARDON3D_PROJECT_DB_INVALID_ARGUMENT); + CHECK(sqlite3_open(path.c_str(), &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA ignore_check_constraints=ON; UPDATE sparse_sfm_tasks SET " + "maximum_tracks=zeroblob(7) WHERE task_id=100;", + nullptr, nullptr, nullptr) == SQLITE_OK); + sqlite3_close(raw); + Lardon3DProjectDbSparseSfmTask corrupt{}; + CHECK(lardon3d_project_db_load_sparse_sfm_task(database, 100, &corrupt) == + LARDON3D_PROJECT_DB_CORRUPT); + CHECK(sqlite3_open(path.c_str(), &raw) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA foreign_keys=ON; DELETE FROM tasks WHERE task_id=101;", + nullptr, nullptr, nullptr) == SQLITE_OK); + sqlite3_close(raw); + CHECK(lardon3d_project_db_load_sparse_sfm_task(database, 101, &corrupt) == + LARDON3D_PROJECT_DB_NOT_FOUND); + lardon3d_project_db_close(database); + CHECK(unlink(path.c_str()) == 0); + CHECK(rmdir(directory) == 0); + return 0; +} diff --git a/tests/test_sparse_sfm_task.cpp b/tests/test_sparse_sfm_task.cpp new file mode 100644 index 0000000..2a17862 --- /dev/null +++ b/tests/test_sparse_sfm_task.cpp @@ -0,0 +1,368 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +#include +} +#include "../src/sparse_sfm_gate_f_internal.h" + +extern "C" int lardon3d_sparse_sfm_task_test_observation_lookup( + const Lardon3DSparseIncrementalObservation *observations, size_t count, + uint64_t track_id, uint64_t image_id, uint64_t feature_set_id, + uint32_t feature_index, size_t *resolved_index); +extern "C" bool lardon3d_sparse_sfm_task_test_project_landmarks( + const Lardon3DSparseIncrementalLandmark *landmarks, size_t landmark_count, + const uint64_t *component_keys, size_t component_count, + Lardon3DSparseLandmark *output, size_t output_capacity, size_t *output_count); + +namespace { +#define CHECK(value) \ + do { \ + if (!(value)) { \ + std::fprintf(stderr, "sparse-sfm-task:%d: %s\n", __LINE__, #value); \ + return false; \ + } \ + } while (0) + +constexpr size_t kImageCount = 3; +constexpr size_t kTrackCount = 12; + +struct Fixture { + std::string root; + std::string database_path; + Lardon3DAppState state{}; + Lardon3DProjectDbScanSet scanset{}; + Lardon3DProjectDbImage images[kImageCount]{}; + Lardon3DProjectDbFeatureSet feature_sets[kImageCount]{}; + Lardon3DProjectDbTrackSet track_set{}; + Lardon3DSparseCalibrationScope scope{}; + Lardon3DSparseIncrementalParameters parameters{}; +}; + +Lardon3DResourcePolicy policy() { + return {0, 0, 0, 0, 1.0, 100.0, 100.0, 100.0, 0, 1}; +} + +bool wait_state(Lardon3DTaskQueue *queue, uint64_t id, Lardon3DTaskState wanted) { + for (size_t attempt = 0; attempt < 4000000; ++attempt) { + Lardon3DTaskSnapshot snapshot{}; + if (lardon3d_task_queue_get(queue, id, &snapshot) && snapshot.state == wanted) return true; + sched_yield(); + } + return false; +} + +Lardon3DSparseGeometryPoint2 project(const Lardon3DSparseGeometryCalibration &calibration, + size_t camera, + const Lardon3DSparseGeometryPoint3 &point) { + double x = point.x + static_cast(camera); + return {calibration.fx * x / point.z + calibration.cx, + calibration.fy * point.y / point.z + calibration.cy}; +} + +std::string asset_path(const unsigned char hash[32], const char *kind) { + static constexpr char digits[] = "0123456789abcdef"; + std::string hex(64, '0'); + for (size_t index = 0; index < 32; ++index) { + hex[2 * index] = digits[hash[index] >> 4]; + hex[2 * index + 1] = digits[hash[index] & 15U]; + } + return std::string("assets/") + kind + "/" + hex.substr(0, 2) + "/" + hex; +} + +bool initialize_runtime(Fixture *fixture) { + lardon3d_app_state_init(&fixture->state); + fixture->state.hardware_profile.logical_cpu_count = 16; + fixture->state.hardware_profile.page_size_bytes = 4096; + fixture->state.hardware_profile.memory_total_bytes = 64ULL * 1024 * 1024 * 1024; + std::snprintf(fixture->state.hardware_profile.cpu_architecture, + sizeof(fixture->state.hardware_profile.cpu_architecture), "test"); + auto resource_policy = policy(); + fixture->state.resource_governor = + lardon3d_resource_governor_create(&fixture->state.hardware_profile, &resource_policy); + fixture->state.task_queue = fixture->state.resource_governor + ? lardon3d_task_queue_create( + fixture->state.resource_governor, 8) + : nullptr; + return fixture->state.task_queue != nullptr; +} + +bool create_fixture(Fixture *fixture) { + char root[] = "/tmp/lardon3d-sparse-task-XXXXXX"; + CHECK(mkdtemp(root) != nullptr); + fixture->root = root; + fixture->database_path = fixture->root + "/project.db"; + CHECK(mkdir((fixture->root + "/.lardon3d").c_str(), 0700) == 0); + CHECK(mkdir((fixture->root + "/.lardon3d/checkpoints").c_str(), 0700) == 0); + CHECK(initialize_runtime(fixture)); + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{}; + CHECK(lardon3d_project_db_open(fixture->database_path.c_str(), + &fixture->state.project_db, error) == + LARDON3D_PROJECT_DB_OK); + fixture->state.project_loaded = true; + CHECK(std::snprintf(fixture->state.project_path, sizeof(fixture->state.project_path), "%s", + fixture->root.c_str()) > 0); + CHECK(lardon3d_project_db_create_scanset(fixture->state.project_db, "sfm-task", + &fixture->scanset) == LARDON3D_PROJECT_DB_OK); + + const Lardon3DSparseGeometryCalibration geometry = { + 4000, 3000, 2000.0, 2000.0, 2000.0, 1500.0, 0.0, 0.0, 0.0, 0.0}; + std::vector points; + for (size_t track = 0; track < kTrackCount; ++track) + points.push_back({-1.0 + static_cast(track % 4) * 0.5, + -0.8 + static_cast(track / 4) * 0.4, + 5.0 + static_cast(track % 3) * 0.25}); + + unsigned char feature_fingerprint[32]{}; + for (size_t camera = 0; camera < kImageCount; ++camera) { + unsigned char image_hash[32]{}; + image_hash[0] = static_cast(camera + 1); + std::string relative = asset_path(image_hash, "images"); + Lardon3DProjectDbImageRegisterStatus status{}; + CHECK(lardon3d_project_db_register_image( + fixture->state.project_db, fixture->scanset.scanset_id, image_hash, + relative.c_str(), 1, "synthetic", relative.c_str(), 0, 1, &status, + &fixture->images[camera]) == + LARDON3D_PROJECT_DB_OK); + std::vector keypoints(kTrackCount); + std::vector descriptors(kTrackCount * 32); + for (size_t track = 0; track < kTrackCount; ++track) { + auto pixel = project(geometry, camera, points[track]); + keypoints[track].x = static_cast(pixel.x); + keypoints[track].y = static_cast(pixel.y); + keypoints[track].size = 1.0F; + std::memset(descriptors.data() + track * 32, static_cast(track + camera), 32); + } + Lardon3DExtractedFeatures features{}; + features.image_width = 4000; + features.image_height = 3000; + features.feature_count = static_cast(kTrackCount); + features.keypoints = keypoints.data(); + features.descriptors = descriptors.data(); + features.descriptor_bytes = descriptors.size(); + CHECK(lardon3d_feature_store_publish_v2( + &fixture->state, fixture->images[camera].image_id, 0, "test", 1, + feature_fingerprint, LARDON3D_FEATURE_DESCRIPTOR_U8, 32, 0, &features, + &fixture->feature_sets[camera]) == LARDON3D_FEATURE_STORE_OK); + } + + Lardon3DSparseCalibration calibration{}; + calibration.model_kind = LARDON3D_SPARSE_SFM_CALIBRATION_KIND_PINHOLE; + calibration.model_version = LARDON3D_SPARSE_SFM_CALIBRATION_VERSION; + calibration.width = 4000; + calibration.height = 3000; + calibration.fx = 2000.0; + calibration.fy = 2000.0; + calibration.cx = 2000.0; + calibration.cy = 1500.0; + calibration.provenance_kind = LARDON3D_SPARSE_SFM_PROVENANCE_USER_EXPLICIT; + Lardon3DSparseCalibration stored{}; + CHECK(lardon3d_sparse_calibration_create(fixture->state.project_db, &calibration, &stored) == + LARDON3D_PROJECT_DB_OK); + Lardon3DSparseCalibrationMember members[kImageCount]{}; + for (size_t camera = 0; camera < kImageCount; ++camera) { + members[camera].image_id = fixture->images[camera].image_id; + members[camera].calibration_id = stored.calibration_id; + std::memcpy(members[camera].calibration_hash, stored.scientific_hash, 32); + } + CHECK(lardon3d_sparse_calibration_scope_create(fixture->state.project_db, members, kImageCount, + &fixture->scope) == LARDON3D_PROJECT_DB_OK); + + std::vector observations(kTrackCount * kImageCount); + std::vector tracks(kTrackCount); + for (size_t track = 0; track < kTrackCount; ++track) { + tracks[track].observation_count = kImageCount; + tracks[track].observations = observations.data() + track * kImageCount; + for (size_t camera = 0; camera < kImageCount; ++camera) + tracks[track].observations[camera] = { + fixture->feature_sets[camera].feature_set_id, static_cast(track), + static_cast(camera)}; + } + Lardon3DProjectDbTrackSet configuration{}; + std::snprintf(configuration.builder_kind, sizeof(configuration.builder_kind), "test"); + configuration.builder_version = 1; + configuration.verifier_kind = 1; + configuration.verifier_version = 1; + configuration.gvr_count = 1; + configuration.track_count = kTrackCount; + CHECK(lardon3d_project_db_create_track_set(fixture->state.project_db, &configuration, + tracks.data(), tracks.size(), + &fixture->track_set) == LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_sparse_incremental_parameters_default(&fixture->parameters)); + fixture->parameters.minimum_seed_tracks = 6; + fixture->parameters.minimum_seed_landmarks = 6; + fixture->parameters.minimum_pnp_correspondences = 6; + fixture->parameters.maximum_registration_rounds = 8; + return true; +} + +void destroy_runtime(Fixture *fixture) { + if (fixture->state.task_queue) lardon3d_task_queue_destroy(fixture->state.task_queue); + if (fixture->state.resource_governor) + lardon3d_resource_governor_destroy(fixture->state.resource_governor); + if (fixture->state.project_db) lardon3d_project_db_close(fixture->state.project_db); + fixture->state = {}; +} + +Lardon3DSparseSfmTaskConfiguration configuration(Fixture *fixture) { + return {fixture->track_set.track_set_id, fixture->scope.scope_id, fixture->parameters}; +} + +bool test_lookup() { + Lardon3DSparseIncrementalObservation values[3] = { + {1, 10, 100, 1, 8, 0, 0}, {2, 20, 100, 2, 8, 0, 0}, + {3, 30, 200, 1, 8, 0, 0}}; + size_t index = 99; + CHECK(lardon3d_sparse_sfm_task_test_observation_lookup(values, 3, 1, 10, 100, 1, + &index) == 1 && index == 0); + CHECK(lardon3d_sparse_sfm_task_test_observation_lookup(values, 3, 2, 20, 100, 2, + &index) == 1 && index == 1); + CHECK(lardon3d_sparse_sfm_task_test_observation_lookup(values, 3, 3, 30, 200, 1, + &index) == 1 && index == 2); + CHECK(lardon3d_sparse_sfm_task_test_observation_lookup(values, 3, 1, 10, 999, 1, + &index) == 0); + CHECK(lardon3d_sparse_sfm_task_test_observation_lookup(values, 3, 99, 10, 100, 1, + &index) == 0); + CHECK(lardon3d_sparse_sfm_task_test_observation_lookup(values, 3, 1, 99, 100, 1, + &index) == 0); + values[2].feature_set_id = 100; + CHECK(lardon3d_sparse_sfm_task_test_observation_lookup(values, 3, 1, 10, 100, 1, + &index) == -1); + return true; +} + +bool test_landmark_projection_order() { + Lardon3DSparseIncrementalLandmark landmarks[5]{}; + const uint64_t component_keys[2] = {5, 10}; + const uint64_t input_components[5] = {10, 5, 99, 5, 10}; + const uint64_t input_tracks[5] = {2, 100, 3, 7, 1}; + for (size_t index = 0; index < 5; ++index) { + landmarks[index].component_key = input_components[index]; + landmarks[index].track_id = input_tracks[index]; + landmarks[index].point.z = 1.0; + landmarks[index].observation_count = 2; + } + Lardon3DSparseLandmark projected[5]{}; + size_t count = 0; + CHECK(lardon3d_sparse_sfm_task_test_project_landmarks( + landmarks, 5, component_keys, 2, projected, 5, &count)); + CHECK(count == 4); + CHECK(projected[0].component_key == 5 && projected[0].track_id == 7); + CHECK(projected[1].component_key == 5 && projected[1].track_id == 100); + CHECK(projected[2].component_key == 10 && projected[2].track_id == 1); + CHECK(projected[3].component_key == 10 && projected[3].track_id == 2); + for (size_t index = 0; index < count; ++index) + CHECK(projected[index].component_key != 99); + return true; +} + +bool test_task() { + Fixture fixture; + CHECK(create_fixture(&fixture)); + auto config = configuration(&fixture); + uint64_t task_id = 0; + Lardon3DTask *task = lardon3d_project_create_sparse_sfm_task(&fixture.state, &config, &task_id); + CHECK(task && task_id != 0); + char kind[LARDON3D_TASK_KIND_CAPACITY]{}; + uint32_t kind_version = 0; + CHECK(lardon3d_task_kind(task, kind, &kind_version)); + CHECK(std::strcmp(kind, LARDON3D_SPARSE_SFM_TASK_KIND) == 0 && kind_version == 1); + Lardon3DResourceEstimate estimate{}; + CHECK(lardon3d_task_resource_estimate(task, &estimate)); + CHECK(estimate.memory_fixed_bytes == 135266304 && estimate.desired_cpu_threads == 1 && + estimate.desired_io_slots == 1 && estimate.minimum_batch_size == 1 && + estimate.maximum_batch_size == 1); + Lardon3DProjectDbSparseSfmTask durable{}; + CHECK(lardon3d_project_db_load_sparse_sfm_task(fixture.state.project_db, task_id, &durable) == + LARDON3D_PROJECT_DB_OK); + CHECK(lardon3d_task_queue_add(fixture.state.task_queue, task, nullptr)); + CHECK(wait_state(fixture.state.task_queue, task_id, TASK_COMPLETED)); + + unsigned char fingerprint[32]{}; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&fixture.parameters, fingerprint)); + Lardon3DSparseReconstruction reconstruction{}; + CHECK(lardon3d_sparse_reconstruction_find_exact( + fixture.state.project_db, fixture.track_set.track_set_id, fixture.scope.scope_id, 1, 1, + fingerprint, &reconstruction) == LARDON3D_PROJECT_DB_OK); + CHECK(reconstruction.component_count == 1 && reconstruction.registered_image_count == 3 && + reconstruction.landmark_count >= 6 && + std::isfinite(reconstruction.reprojection_rmse_px) && + std::isfinite(reconstruction.reprojection_median_px)); + Lardon3DSparseLandmarkObservation persisted_observations[64]{}; + Lardon3DSparseObservationPage observation_page{}; + observation_page.items = persisted_observations; + CHECK(lardon3d_sparse_observation_list(fixture.state.project_db, + reconstruction.reconstruction_id, 0, 0, 64, + &observation_page) == LARDON3D_PROJECT_DB_OK); + CHECK(observation_page.count >= 18); + + uint64_t reuse_id = 0; + CHECK(lardon3d_project_enqueue_sparse_sfm_task(&fixture.state, &config, &reuse_id)); + CHECK(wait_state(fixture.state.task_queue, reuse_id, TASK_COMPLETED)); + Lardon3DSparseReconstruction reused{}; + CHECK(lardon3d_sparse_reconstruction_find_exact( + fixture.state.project_db, fixture.track_set.track_set_id, fixture.scope.scope_id, 1, 1, + fingerprint, &reused) == LARDON3D_PROJECT_DB_OK); + CHECK(reused.reconstruction_id == reconstruction.reconstruction_id); + + uint64_t restart_id = 0; + Lardon3DTask *pending = + lardon3d_project_create_sparse_sfm_task(&fixture.state, &config, &restart_id); + CHECK(pending != nullptr); + Lardon3DTaskDurableSnapshot before{}; + CHECK(lardon3d_task_durable_snapshot(pending, &before)); + lardon3d_task_destroy(pending); + 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.c_str(), &fixture.state.project_db, error) == + LARDON3D_PROJECT_DB_OK); + Lardon3DTaskKindDescriptor descriptor{LARDON3D_SPARSE_SFM_TASK_KIND, 1, + lardon3d_sparse_sfm_task_reconstruct}; + Lardon3DTaskKindRegistry registry{}; + CHECK(lardon3d_task_kind_registry_init(®istry, &descriptor, 1)); + Lardon3DTaskReconstructionContext runtime{fixture.root.c_str(), fixture.state.project_db, + fixture.state.resource_governor, nullptr}; + Lardon3DTask *restored = nullptr; + CHECK(lardon3d_task_kind_registry_restore(®istry, LARDON3D_SPARSE_SFM_TASK_KIND, 1, + &before, &runtime, &restored) == + LARDON3D_TASK_KIND_OK); + Lardon3DResourceEstimate restored_estimate{}; + CHECK(lardon3d_task_resource_estimate(restored, &restored_estimate)); + CHECK(std::memcmp(&estimate, &restored_estimate, sizeof(estimate)) == 0); + Lardon3DProjectDbSparseSfmTask restored_payload{}; + CHECK(lardon3d_project_db_load_sparse_sfm_task(fixture.state.project_db, restart_id, + &restored_payload) == LARDON3D_PROJECT_DB_OK); + unsigned char restored_fingerprint[32]{}; + CHECK(lardon3d_sparse_sfm_parameter_fingerprint(&restored_payload.parameters, + restored_fingerprint)); + CHECK(std::memcmp(fingerprint, restored_fingerprint, 32) == 0); + unsigned char record_before[372]{}; + unsigned char record_after[372]{}; + CHECK(lardon3d_sparse_sfm_fingerprint_record(&fixture.parameters, record_before)); + CHECK(lardon3d_sparse_sfm_fingerprint_record(&restored_payload.parameters, record_after)); + CHECK(std::memcmp(record_before, record_after, sizeof(record_before)) == 0); + CHECK(lardon3d_task_queue_add(fixture.state.task_queue, restored, nullptr)); + CHECK(wait_state(fixture.state.task_queue, restart_id, TASK_COMPLETED)); + destroy_runtime(&fixture); + return true; +} +} // namespace + +int main() { + return test_lookup() && test_landmark_projection_order() && test_task() ? 0 : 1; +}