feat: add multi-source acquisition ingestion

This commit is contained in:
fy59 2026-08-27 16:43:44 +02:00
parent 961d92fff3
commit 7886b38553
7 changed files with 721 additions and 3 deletions

View file

@ -17,6 +17,37 @@ est acceptée sans nouvelle ligne ni migration de schéma. Elle ne crée aucune
image logique, ne modifie aucune sélection et ne réalise aucun appariement
automatique.
### S3-E — Orchestration d'ingestion multi-source v1
**IMPLEMENTED / VALIDATION PENDING.** S3-E reçoit un ScanSet explicite et au
plus 64 chemins source explicitement fournis par l'appelant ; il ne parcourt
jamais un répertoire. Chaque fichier est d'abord publié comme asset immuable
géré, puis son évidence S3-D est extraite depuis ces octets publiés. Le
regroupement automatique ne crée un Capture commun que pour une relation
`SAME_ACQUISITION_STRONG` unique et mutuelle. Les noms, chemins, SHA-256,
timestamps, modèle, évidence faible, contradictions et ambiguïtés ne sont
jamais une identité de Capture et ne donnent jamais lieu à un rapprochement
automatique. Deux assets identiques dans une requête sont rejetés de façon
déterministe.
L'appelant peut aussi déclarer explicitement des groupes. Ce résultat porte la
basis `CALLER_EXPLICIT`, distincte de la basis scientifique `STRONG`; cette
provenance est un résultat de l'opération v1, non une nouvelle colonne durable.
Les siblings sont persistés exclusivement avec S3-C. Pour la représentation,
un JPEG caméra reste un asset `SOURCE` et devient une image logique du même
Capture via la publication source-image transactionnelle; un RAW reste
`SOURCE` et son image est produite seulement par S3-B1, avec son PNG `DERIVED`.
RAW et JPEG d'une même acquisition ne créent pas deux Captures. La sélection ne
change que si l'appelant demande explicitement la représentation sélectionnée.
Project DB reste strictement v19, sans migration ni fusion de Captures. Après
qu'un Capture ID a été renvoyé ou durablement retenu, une reprise doit fournir
explicitement `resume_capture_id`: publication, attaches S3-C et représentation
convergent alors sans réhoming ni doublon logique. Une panne avant que
l'appelant ait retenu cet ID ne peut pas offrir une reprise whole-request
exactly-once en v19; S3-E n'infère volontairement jamais une identité de
reprise depuis SHA, chemin, basename, timestamp, métadonnées ou `image_id`.
`asset_derivations` est volontairement limité à un parent asset et un enfant
asset, avec kind/version et fingerprint canonique de 32 octets. Il n'est pas un
DAG générique et ne réalise aucun développement RAW ni extraction vidéo. La
@ -44,9 +75,8 @@ assets sont différents. Une valeur absente n'est pas un conflit. Une ambiguït
n'est jamais résolue par l'ordre des assets.
S3-D n'effectue aucune écriture en base ni changement de schéma : Project DB
reste v19 et S3-C attach demeure l'unique primitive de persistance. S3-E, la
future intégration d'ingestion multi-source avant `Capture`, n'est pas
implémentée.
reste v19 et S3-C attach demeure l'unique primitive de persistance. S3-E
réutilise cette évidence sans la redéfinir.
## Phase H — décision Project DB v18

View file

@ -0,0 +1,81 @@
#ifndef LARDON3D_ACQUISITION_INGEST_H
#define LARDON3D_ACQUISITION_INGEST_H
#include <stddef.h>
#include <stdint.h>
#include <lardon3d/app_state.h>
#ifdef __cplusplus
extern "C" {
#endif
#define LARDON3D_ACQUISITION_INGEST_MAX_SOURCES 64u
typedef enum {
LARDON3D_ACQUISITION_INGEST_OK = 0,
LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT,
LARDON3D_ACQUISITION_INGEST_SCANSET_NOT_FOUND,
LARDON3D_ACQUISITION_INGEST_PUBLISH_ERROR,
LARDON3D_ACQUISITION_INGEST_METADATA_ERROR,
LARDON3D_ACQUISITION_INGEST_DUPLICATE_ASSET,
LARDON3D_ACQUISITION_INGEST_DB_ERROR,
LARDON3D_ACQUISITION_INGEST_DEVELOPMENT_ERROR,
LARDON3D_ACQUISITION_INGEST_CONSTRAINT,
LARDON3D_ACQUISITION_INGEST_INTERNAL_ERROR
} Lardon3DAcquisitionIngestResult;
typedef enum {
LARDON3D_ACQUISITION_GROUP_AUTOMATIC = 1,
LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT = 2
} Lardon3DAcquisitionGroupingMode;
typedef enum {
LARDON3D_ACQUISITION_GROUP_SINGLETON = 0,
LARDON3D_ACQUISITION_GROUP_STRONG = 1,
LARDON3D_ACQUISITION_GROUP_EXPLICIT = 2
} Lardon3DAcquisitionGroupBasis;
typedef enum {
LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE = 1,
LARDON3D_ACQUISITION_SELECT_DEVELOP_RAW = 2
} Lardon3DAcquisitionRepresentation;
typedef struct {
const char *source_path; /* Borrowed for the duration of the call. */
uint32_t explicit_group;
} Lardon3DAcquisitionIngestSource;
typedef struct {
Lardon3DAcquisitionGroupingMode grouping;
Lardon3DAcquisitionRepresentation representation;
uint8_t select_representation;
uint8_t reserved[7];
uint64_t resume_capture_id;
uint64_t producer_task_id;
int64_t imported_at;
uint64_t max_source_bytes;
} Lardon3DAcquisitionIngestOptions;
typedef struct {
uint64_t capture_id;
Lardon3DAcquisitionGroupBasis basis;
size_t source_count;
size_t source_indices[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES];
} Lardon3DAcquisitionIngestGroup;
typedef struct {
size_t group_count;
Lardon3DAcquisitionIngestGroup groups[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES];
} Lardon3DAcquisitionIngestOutput;
Lardon3DAcquisitionIngestResult lardon3d_acquisition_ingest(
Lardon3DAppState *state, uint64_t scanset_id,
const Lardon3DAcquisitionIngestSource *sources, size_t source_count,
const Lardon3DAcquisitionIngestOptions *options, Lardon3DAcquisitionIngestOutput *output);
#ifdef __cplusplus
}
#endif
#endif

View file

@ -510,6 +510,11 @@ Lardon3DProjectDbResult lardon3d_project_db_publish_derived_capture_image(
const char *original_name, const char *source_path, uint64_t producer_task_id,
int64_t imported_at, Lardon3DProjectDbImageRegisterStatus *status,
Lardon3DProjectDbImage *image);
Lardon3DProjectDbResult lardon3d_project_db_publish_source_capture_image(
Lardon3DProjectDb *database, uint64_t capture_id, uint64_t asset_id,
const char *original_name, const char *source_path, uint64_t producer_task_id,
int64_t imported_at, bool select_image, Lardon3DProjectDbImageRegisterStatus *status,
Lardon3DProjectDbImage *image);
Lardon3DProjectDbResult lardon3d_project_db_load_image(Lardon3DProjectDb *database,
uint64_t image_id,
Lardon3DProjectDbImage *image,

View file

@ -150,6 +150,7 @@ executable(
'src/image_catalog_persistent.c',
'src/raw_development.cpp',
'src/acquisition_pairing.cpp',
'src/acquisition_ingest.cpp',
'src/feature_extractor_opencv.cpp',
'src/feature_store.c',
'src/feature_extractor_opencv.cpp',
@ -333,6 +334,27 @@ acquisition_pairing_test = executable(
test('acquisition-pairing', acquisition_pairing_test, timeout: 30)
acquisition_ingest_test = executable(
'test-acquisition-ingest',
sources: [
'tests/test_acquisition_ingest.cpp',
'src/app_state.c',
'src/image_catalog_persistent.c',
'src/acquisition_ingest.cpp',
'src/acquisition_pairing.cpp',
'src/raw_development.cpp',
'src/project_db.c', 'src/project_db_sparse_sfm.c',
'src/task.c',
'src/resource_governor.c',
'src/resource_snapshot.c',
],
include_directories: include_directories('include'),
cpp_args: ['-DLARDON3D_ACQUISITION_INGEST_TESTING'],
dependencies: [threads, sqlite3, openssl, opencv, libraw, libexif, libpng, libdeflate],
)
test('acquisition-ingest', acquisition_ingest_test, timeout: 30)
feature_store_test = executable(
'test-feature-store',
sources: [

195
src/acquisition_ingest.cpp Normal file
View file

@ -0,0 +1,195 @@
#include <lardon3d/acquisition_ingest.h>
#include <lardon3d/acquisition_pairing.h>
extern "C" {
#include <lardon3d/image_catalog.h>
}
#include <lardon3d/raw_development.h>
#include <climits>
#include <cstdio>
#include <cstring>
namespace {
#ifdef LARDON3D_ACQUISITION_INGEST_TESTING
const Lardon3DAcquisitionMetadata *testing_metadata = nullptr;
size_t testing_metadata_count = 0u;
#endif
struct PublishedSource {
Lardon3DProjectDbImageAsset asset;
Lardon3DAcquisitionMetadata metadata;
char managed_path[PATH_MAX];
};
bool valid_options(const Lardon3DAcquisitionIngestOptions &options) {
return (options.grouping == LARDON3D_ACQUISITION_GROUP_AUTOMATIC ||
options.grouping == LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT) &&
(options.representation == LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE ||
options.representation == LARDON3D_ACQUISITION_SELECT_DEVELOP_RAW) &&
options.select_representation <= 1u && options.imported_at >= 0 &&
options.max_source_bytes != 0u;
}
const char *base_name(const char *path) {
const char *slash = std::strrchr(path, '/');
return slash == nullptr ? path : slash + 1;
}
Lardon3DAcquisitionIngestResult db_result(Lardon3DProjectDbResult result) {
if (result == LARDON3D_PROJECT_DB_CONSTRAINT) return LARDON3D_ACQUISITION_INGEST_CONSTRAINT;
return result == LARDON3D_PROJECT_DB_NOT_FOUND ? LARDON3D_ACQUISITION_INGEST_SCANSET_NOT_FOUND
: LARDON3D_ACQUISITION_INGEST_DB_ERROR;
}
bool add_to_group(Lardon3DAcquisitionIngestGroup &group, size_t source_index) {
if (group.source_count >= LARDON3D_ACQUISITION_INGEST_MAX_SOURCES) return false;
group.source_indices[group.source_count++] = source_index;
return true;
}
} // namespace
#ifdef LARDON3D_ACQUISITION_INGEST_TESTING
extern "C" void lardon3d_acquisition_ingest_test_set_metadata(
const Lardon3DAcquisitionMetadata *metadata, size_t count) {
testing_metadata = metadata;
testing_metadata_count = count;
}
#endif
extern "C" Lardon3DAcquisitionIngestResult lardon3d_acquisition_ingest(
Lardon3DAppState *state, uint64_t scanset_id,
const Lardon3DAcquisitionIngestSource *sources, size_t source_count,
const Lardon3DAcquisitionIngestOptions *options, Lardon3DAcquisitionIngestOutput *output) {
if (output != nullptr) std::memset(output, 0, sizeof(*output));
if (state == nullptr || !state->project_loaded || state->project_db == nullptr || scanset_id == 0u ||
sources == nullptr || source_count == 0u ||
source_count > LARDON3D_ACQUISITION_INGEST_MAX_SOURCES || options == nullptr ||
!valid_options(*options) || output == nullptr) return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT;
Lardon3DProjectDbScanSet scanset{};
Lardon3DProjectDbResult database_result =
lardon3d_project_db_load_scanset(state->project_db, scanset_id, &scanset);
if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result);
if (options->grouping == LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT) {
for (size_t i = 0; i < source_count; ++i)
if (sources[i].explicit_group == 0u) return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT;
}
PublishedSource published[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{};
for (size_t i = 0; i < source_count; ++i) {
if (sources[i].source_path == nullptr || sources[i].source_path[0] == '\0')
return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT;
if (lardon3d_image_catalog_publish_asset_file(state, sources[i].source_path,
options->imported_at, options->max_source_bytes, &published[i].asset) !=
LARDON3D_IMAGE_CATALOG_ASSET_PUBLISHED) return LARDON3D_ACQUISITION_INGEST_PUBLISH_ERROR;
for (size_t previous = 0; previous < i; ++previous)
if (published[previous].asset.asset_id == published[i].asset.asset_id)
return LARDON3D_ACQUISITION_INGEST_DUPLICATE_ASSET;
int length = std::snprintf(published[i].managed_path, sizeof(published[i].managed_path),
"%s/%s", state->project_path, published[i].asset.path);
if (length <= 0 || static_cast<size_t>(length) >= sizeof(published[i].managed_path))
return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT;
if (lardon3d_acquisition_extract_metadata(published[i].managed_path,
&published[i].metadata) != LARDON3D_ACQUISITION_OK)
return LARDON3D_ACQUISITION_INGEST_METADATA_ERROR;
#ifdef LARDON3D_ACQUISITION_INGEST_TESTING
if (testing_metadata != nullptr) {
if (testing_metadata_count != source_count) return LARDON3D_ACQUISITION_INGEST_INTERNAL_ERROR;
published[i].metadata = testing_metadata[i];
}
#endif
}
bool assigned[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{};
if (options->grouping == LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT) {
for (size_t i = 0; i < source_count; ++i) {
if (assigned[i]) continue;
Lardon3DAcquisitionIngestGroup &group = output->groups[output->group_count++];
group.basis = LARDON3D_ACQUISITION_GROUP_EXPLICIT;
for (size_t j = i; j < source_count; ++j) {
if (sources[j].explicit_group == sources[i].explicit_group) {
(void)add_to_group(group, j);
assigned[j] = true;
}
}
}
} else {
size_t strong_count[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{};
size_t strong_partner[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{};
for (size_t i = 0; i < source_count; ++i) for (size_t j = i + 1; j < source_count; ++j) {
Lardon3DAcquisitionPairResult pair{};
if (lardon3d_acquisition_compare(&published[i].metadata, &published[j].metadata, 0, 0,
&pair) != LARDON3D_ACQUISITION_OK)
return LARDON3D_ACQUISITION_INGEST_INTERNAL_ERROR;
if (pair.decision == LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG) {
++strong_count[i]; ++strong_count[j]; strong_partner[i] = j; strong_partner[j] = i;
}
}
for (size_t i = 0; i < source_count; ++i) {
if (assigned[i]) continue;
Lardon3DAcquisitionIngestGroup &group = output->groups[output->group_count++];
(void)add_to_group(group, i); assigned[i] = true;
size_t partner = strong_partner[i];
if (strong_count[i] == 1u && strong_count[partner] == 1u && strong_partner[partner] == i) {
(void)add_to_group(group, partner); assigned[partner] = true;
group.basis = LARDON3D_ACQUISITION_GROUP_STRONG;
} else group.basis = LARDON3D_ACQUISITION_GROUP_SINGLETON;
}
}
if (options->resume_capture_id != 0u && output->group_count != 1u)
return LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT;
for (size_t group_index = 0; group_index < output->group_count; ++group_index) {
Lardon3DAcquisitionIngestGroup &group = output->groups[group_index];
Lardon3DProjectDbCapture capture{};
if (options->resume_capture_id != 0u) {
database_result = lardon3d_project_db_load_capture(state->project_db,
options->resume_capture_id, &capture);
if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result);
if (capture.scanset_id != scanset_id) return LARDON3D_ACQUISITION_INGEST_CONSTRAINT;
} else {
database_result = lardon3d_project_db_create_capture(state->project_db, scanset_id,
options->imported_at, &capture);
if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result);
}
group.capture_id = capture.capture_id;
size_t jpeg = SIZE_MAX, raw = SIZE_MAX;
for (size_t member = 0; member < group.source_count; ++member) {
size_t index = group.source_indices[member];
database_result = lardon3d_project_db_attach_capture_source_asset(
state->project_db, capture.capture_id, published[index].asset.asset_id);
if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result);
if (published[index].metadata.source_kind == LARDON3D_ACQUISITION_SOURCE_JPEG && jpeg == SIZE_MAX)
jpeg = index;
if (published[index].metadata.source_kind == LARDON3D_ACQUISITION_SOURCE_RAW && raw == SIZE_MAX)
raw = index;
}
bool use_jpeg = jpeg != SIZE_MAX &&
(options->representation == LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE || raw == SIZE_MAX);
if (use_jpeg) {
Lardon3DProjectDbImage image{};
Lardon3DProjectDbImageRegisterStatus status{};
database_result = lardon3d_project_db_publish_source_capture_image(
state->project_db, capture.capture_id, published[jpeg].asset.asset_id,
base_name(sources[jpeg].source_path), sources[jpeg].source_path,
options->producer_task_id, options->imported_at, options->select_representation != 0u,
&status, &image);
if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result);
} else if (raw != SIZE_MAX) {
Lardon3DRawDevelopmentOutput developed{};
Lardon3DRawDevelopmentResult result = lardon3d_raw_develop_to_capture(
state, capture.capture_id, published[raw].managed_path, options->producer_task_id,
options->imported_at, &developed);
if (result != LARDON3D_RAW_DEVELOPMENT_OK)
return LARDON3D_ACQUISITION_INGEST_DEVELOPMENT_ERROR;
if (options->select_representation) {
database_result = lardon3d_project_db_set_selected_capture_image(
state->project_db, capture.capture_id, developed.image.image_id);
if (database_result != LARDON3D_PROJECT_DB_OK) return db_result(database_result);
}
} else return LARDON3D_ACQUISITION_INGEST_CONSTRAINT;
}
return LARDON3D_ACQUISITION_INGEST_OK;
}

View file

@ -2950,6 +2950,128 @@ Lardon3DProjectDbResult lardon3d_project_db_publish_derived_capture_image(
&(Lardon3DProjectDbImageAsset){0});
}
Lardon3DProjectDbResult lardon3d_project_db_publish_source_capture_image(
Lardon3DProjectDb *database, uint64_t capture_id, uint64_t asset_id,
const char *original_name, const char *source_path, uint64_t producer_task_id,
int64_t imported_at, bool select_image, Lardon3DProjectDbImageRegisterStatus *status,
Lardon3DProjectDbImage *image) {
if (!database || !valid_catalog_id(capture_id) || !valid_catalog_id(asset_id) ||
!valid_original_name(original_name) ||
!bounded_text(source_path, LARDON3D_PROJECT_DB_PATH_CAPACITY, false) ||
(producer_task_id != 0 && !valid_task_id(producer_task_id)) || imported_at < 0 || !status ||
!image) {
return LARDON3D_PROJECT_DB_INVALID_ARGUMENT;
}
memset(image, 0, sizeof(*image));
*status = LARDON3D_PROJECT_DB_IMAGE_REGISTERED;
(void)pthread_mutex_lock(&database->mutex);
Lardon3DProjectDbResult result =
execute(database, "BEGIN IMMEDIATE", "begin source capture image publication");
sqlite3_stmt *statement = NULL;
sqlite3_int64 scanset_id = 0;
if (result == LARDON3D_PROJECT_DB_OK) {
result = prepare(database, "SELECT scanset_id FROM captures WHERE capture_id=?1", &statement);
}
if (result == LARDON3D_PROJECT_DB_OK) {
(void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id);
int code = sqlite3_step(statement);
if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND;
else if (code != SQLITE_ROW || (scanset_id = sqlite3_column_int64(statement, 0)) <= 0)
result = code == SQLITE_ROW ? LARDON3D_PROJECT_DB_CORRUPT
: sqlite_result(database, code, "load source image capture");
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (result == LARDON3D_PROJECT_DB_OK) {
result = prepare(database, "SELECT 1 FROM image_assets WHERE asset_id=?1 AND state=1", &statement);
}
if (result == LARDON3D_PROJECT_DB_OK) {
(void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)asset_id);
int code = sqlite3_step(statement);
if (code == SQLITE_DONE) result = LARDON3D_PROJECT_DB_NOT_FOUND;
else if (code != SQLITE_ROW) result = sqlite_result(database, code, "load source image asset");
(void)sqlite3_finalize(statement);
statement = NULL;
}
int inserted = 0;
uint64_t image_id = 0;
if (result == LARDON3D_PROJECT_DB_OK) {
result = insert_or_find_image_locked(database, (uint64_t)scanset_id, asset_id, original_name,
source_path, producer_task_id, imported_at, &inserted,
&image_id);
}
if (result == LARDON3D_PROJECT_DB_OK) {
result = prepare(database, "SELECT capture_id FROM capture_images WHERE image_id=?1", &statement);
}
bool attach_image = false;
if (result == LARDON3D_PROJECT_DB_OK) {
(void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)image_id);
int code = sqlite3_step(statement);
if (code == SQLITE_DONE) attach_image = true;
else if (code != SQLITE_ROW) result = sqlite_result(database, code, "find source image capture");
else if (sqlite3_column_int64(statement, 0) != (sqlite3_int64)capture_id)
result = LARDON3D_PROJECT_DB_CONSTRAINT;
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (result == LARDON3D_PROJECT_DB_OK) {
result = prepare(database, "SELECT role FROM capture_assets WHERE capture_id=?1 AND asset_id=?2",
&statement);
}
bool attach_asset = false;
if (result == LARDON3D_PROJECT_DB_OK) {
(void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id);
(void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)asset_id);
int code = sqlite3_step(statement);
if (code == SQLITE_DONE) attach_asset = true;
else if (code != SQLITE_ROW) result = sqlite_result(database, code, "find source capture asset");
else if (sqlite3_column_int(statement, 0) != LARDON3D_DB_CAPTURE_ASSET_SOURCE)
result = LARDON3D_PROJECT_DB_CONSTRAINT;
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (result == LARDON3D_PROJECT_DB_OK && attach_asset) {
result = prepare(database, "INSERT INTO capture_assets(capture_id,asset_id,role) VALUES(?1,?2,1)",
&statement);
if (result == LARDON3D_PROJECT_DB_OK) {
(void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id);
(void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)asset_id);
result = step_done(database, statement, "attach source capture asset");
statement = NULL;
}
}
if (result == LARDON3D_PROJECT_DB_OK && attach_image) {
result = prepare(database, "INSERT INTO capture_images(capture_id,image_id) VALUES(?1,?2)", &statement);
if (result == LARDON3D_PROJECT_DB_OK) {
(void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id);
(void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)image_id);
result = step_done(database, statement, "attach source capture image");
statement = NULL;
}
}
if (result == LARDON3D_PROJECT_DB_OK && select_image) {
result = prepare(database, "INSERT INTO capture_selections(capture_id,image_id) VALUES(?1,?2) "
"ON CONFLICT(capture_id) DO UPDATE SET image_id=excluded.image_id",
&statement);
if (result == LARDON3D_PROJECT_DB_OK) {
(void)sqlite3_bind_int64(statement, 1, (sqlite3_int64)capture_id);
(void)sqlite3_bind_int64(statement, 2, (sqlite3_int64)image_id);
result = step_done(database, statement, "select source capture image");
statement = NULL;
}
}
if (result == LARDON3D_PROJECT_DB_OK)
result = execute(database, "COMMIT", "commit source capture image publication");
if (result != LARDON3D_PROJECT_DB_OK)
(void)execute(database, "ROLLBACK", "rollback source capture image publication");
(void)pthread_mutex_unlock(&database->mutex);
if (result != LARDON3D_PROJECT_DB_OK) return result;
*status = inserted == 1 ? LARDON3D_PROJECT_DB_IMAGE_REGISTERED
: LARDON3D_PROJECT_DB_IMAGE_ALREADY_PRESENT;
return lardon3d_project_db_load_image(database, image_id, image,
&(Lardon3DProjectDbImageAsset){0});
}
Lardon3DProjectDbResult lardon3d_project_db_load_image(Lardon3DProjectDb *database,
uint64_t image_id,
Lardon3DProjectDbImage *image,

View file

@ -0,0 +1,263 @@
#include <lardon3d/acquisition_ingest.h>
#include <lardon3d/acquisition_pairing.h>
extern "C" {
#include <lardon3d/image_catalog.h>
}
#include <libexif/exif-data.h>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <dirent.h>
#include <fcntl.h>
#include <string>
#include <sys/stat.h>
#include <unistd.h>
extern "C" void lardon3d_acquisition_ingest_test_set_metadata(
const Lardon3DAcquisitionMetadata *metadata, size_t count);
#define CHECK(condition) do { \
if (!(condition)) { std::fprintf(stderr, "Failure line %d: %s\\n", __LINE__, #condition); return false; } \
} while (0)
namespace {
bool remove_tree(const char *path) {
struct stat info {};
if (lstat(path, &info) != 0) return errno == ENOENT;
if (!S_ISDIR(info.st_mode)) return unlink(path) == 0;
DIR *directory = opendir(path);
if (directory == nullptr) return false;
bool ok = true;
for (dirent *entry = readdir(directory); entry != nullptr; entry = readdir(directory)) {
if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) continue;
char child[4096];
const int length = std::snprintf(child, sizeof(child), "%s/%s", path, entry->d_name);
if (length <= 0 || static_cast<size_t>(length) >= sizeof(child) || !remove_tree(child)) ok = false;
}
return closedir(directory) == 0 && rmdir(path) == 0 && ok;
}
void add_ascii(ExifData *data, ExifIfd ifd, ExifTag tag, const char *text) {
ExifEntry *entry = exif_entry_new();
entry->tag = tag;
entry->format = EXIF_FORMAT_ASCII;
entry->components = std::strlen(text) + 1u;
entry->size = static_cast<unsigned int>(entry->components);
entry->data = static_cast<unsigned char *>(std::malloc(entry->size));
std::memcpy(entry->data, text, entry->size);
exif_content_add_entry(data->ifd[ifd], entry);
exif_entry_unref(entry);
}
bool write_jpeg(const char *path, const char *unique_id) {
ExifData *data = exif_data_new();
if (data == nullptr) return false;
exif_data_set_byte_order(data, EXIF_BYTE_ORDER_INTEL);
add_ascii(data, EXIF_IFD_0, EXIF_TAG_MODEL, "Lardon test camera");
if (unique_id != nullptr) add_ascii(data, EXIF_IFD_EXIF, EXIF_TAG_IMAGE_UNIQUE_ID, unique_id);
unsigned char *encoded = nullptr;
unsigned int encoded_size = 0u;
exif_data_save_data(data, &encoded, &encoded_size);
FILE *file = std::fopen(path, "wb");
const unsigned char soi[] = {0xffu, 0xd8u, 0xffu, 0xe1u};
const unsigned int segment_size = encoded_size + 2u;
const unsigned char length[] = {static_cast<unsigned char>(segment_size >> 8u),
static_cast<unsigned char>(segment_size)};
const unsigned char eoi[] = {0xffu, 0xd9u};
const bool ok = file != nullptr && encoded != nullptr && encoded_size != 0u &&
segment_size <= UINT16_MAX && std::fwrite(soi, 1, sizeof(soi), file) == sizeof(soi) &&
std::fwrite(length, 1, sizeof(length), file) == sizeof(length) &&
std::fwrite(encoded, 1, encoded_size, file) == encoded_size &&
std::fwrite(eoi, 1, sizeof(eoi), file) == sizeof(eoi) && std::fclose(file) == 0;
if (file != nullptr && !ok) std::fclose(file);
std::free(encoded);
exif_data_unref(data);
return ok;
}
struct Fixture {
char root[4096] = "/tmp/lardon3d-ingest-XXXXXX";
char database_path[4096]{};
Lardon3DProjectDb *database = nullptr;
Lardon3DAppState state{};
Lardon3DProjectDbScanSet scanset{};
bool open() {
if (mkdtemp(root) == nullptr || std::snprintf(database_path, sizeof(database_path), "%s/project.db", root) <= 0)
return false;
char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{};
if (lardon3d_project_db_open(database_path, &database, error) != LARDON3D_PROJECT_DB_OK) return false;
state.project_loaded = true;
state.project_db = database;
if (std::snprintf(state.project_path, sizeof(state.project_path), "%s", root) <= 0) return false;
return lardon3d_project_db_create_scanset(database, "S3-E", &scanset) == LARDON3D_PROJECT_DB_OK;
}
void close() {
lardon3d_acquisition_ingest_test_set_metadata(nullptr, 0u);
if (database != nullptr) lardon3d_project_db_close(database);
database = nullptr;
if (root[0] != '\0') (void)remove_tree(root);
}
bool path(char output[4096], const char *name) const {
const int length = std::snprintf(output, 4096, "%s/%s", root, name);
return length > 0 && length < 4096;
}
};
Lardon3DAcquisitionIngestOptions options() {
Lardon3DAcquisitionIngestOptions value{};
value.grouping = LARDON3D_ACQUISITION_GROUP_AUTOMATIC;
value.representation = LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE;
value.imported_at = 7;
value.max_source_bytes = 1024 * 1024;
return value;
}
Lardon3DAcquisitionMetadata metadata(const char *uid, Lardon3DAcquisitionSourceKind kind = LARDON3D_ACQUISITION_SOURCE_JPEG) {
Lardon3DAcquisitionMetadata value{};
value.policy_version = LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION;
value.source_kind = kind;
if (uid != nullptr) {
std::strcpy(value.image_unique_id, uid);
value.present_fields = LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID;
}
return value;
}
size_t capture_count(Lardon3DProjectDb *database, uint64_t scanset_id) {
Lardon3DProjectDbCapture captures[64]{};
size_t count = 0u;
return lardon3d_project_db_list_captures(database, scanset_id, 0, captures, 64, &count) ==
LARDON3D_PROJECT_DB_OK ? count : SIZE_MAX;
}
bool capture_assets(Lardon3DProjectDb *database, uint64_t capture_id, size_t expected) {
Lardon3DProjectDbCaptureAsset assets[64]{};
size_t count = 0u;
if (lardon3d_project_db_list_capture_assets(database, capture_id, 0, assets, 64, &count) !=
LARDON3D_PROJECT_DB_OK || count != expected) return false;
for (size_t i = 0; i < count; ++i)
if (assets[i].role != LARDON3D_DB_CAPTURE_ASSET_SOURCE) return false;
return true;
}
bool test_ingest_grouping_and_resume() {
Fixture fixture;
CHECK(fixture.open());
char a[4096], b[4096], c[4096], d[4096];
CHECK(fixture.path(a, "DSC03350.JPG") && fixture.path(b, "DSC03350.ARW") &&
fixture.path(c, "other.JPG") && fixture.path(d, "fourth.JPG"));
CHECK(write_jpeg(a, "A") && write_jpeg(b, "B") && write_jpeg(c, "C") && write_jpeg(d, "D"));
Lardon3DAcquisitionIngestSource sources[] = {{a, 0}, {b, 0}};
Lardon3DAcquisitionIngestOutput output{};
Lardon3DAcquisitionIngestOptions automatic_options = options();
/* Same basename and second-level timestamp are not passed as pairing hints. */
Lardon3DAcquisitionMetadata weak[] = {metadata(nullptr), metadata(nullptr)};
lardon3d_acquisition_ingest_test_set_metadata(weak, 2);
CHECK(lardon3d_acquisition_ingest(&fixture.state, fixture.scanset.scanset_id, sources, 2,
&automatic_options, &output) == LARDON3D_ACQUISITION_INGEST_OK);
CHECK(output.group_count == 2 && output.groups[0].basis == LARDON3D_ACQUISITION_GROUP_SINGLETON &&
output.groups[1].basis == LARDON3D_ACQUISITION_GROUP_SINGLETON && capture_count(fixture.database, fixture.scanset.scanset_id) == 2);
/* Controlled S3-D evidence: distinct source kinds, one unique mutual strong pair. */
Fixture strong;
CHECK(strong.open());
char raw_like[4096], jpeg[4096];
CHECK(strong.path(raw_like, "raw-like.bin") && strong.path(jpeg, "camera.JPG") &&
write_jpeg(raw_like, "physical-one") && write_jpeg(jpeg, "physical-two"));
Lardon3DAcquisitionIngestSource pair[] = {{raw_like, 0}, {jpeg, 0}};
Lardon3DAcquisitionMetadata same[] = {metadata("SHUTTER", LARDON3D_ACQUISITION_SOURCE_RAW),
metadata("SHUTTER", LARDON3D_ACQUISITION_SOURCE_JPEG)};
lardon3d_acquisition_ingest_test_set_metadata(same, 2);
Lardon3DAcquisitionIngestOutput paired{};
Lardon3DAcquisitionIngestOptions jpeg_options = options();
CHECK(lardon3d_acquisition_ingest(&strong.state, strong.scanset.scanset_id, pair, 2,
&jpeg_options, &paired) == LARDON3D_ACQUISITION_INGEST_OK);
CHECK(paired.group_count == 1 && paired.groups[0].basis == LARDON3D_ACQUISITION_GROUP_STRONG &&
paired.groups[0].source_count == 2 && capture_count(strong.database, strong.scanset.scanset_id) == 1 &&
capture_assets(strong.database, paired.groups[0].capture_id, 2));
/* A competing equal strong candidate remains entirely ungrouped. */
Fixture ambiguous;
CHECK(ambiguous.open());
char paths[3][4096];
const char *physical_ids[] = {"physical-a", "physical-b", "physical-c"};
for (size_t i = 0; i < 3; ++i) {
CHECK(ambiguous.path(paths[i], i == 0 ? "a.JPG" : i == 1 ? "b.JPG" : "c.JPG"));
CHECK(write_jpeg(paths[i], physical_ids[i]));
}
Lardon3DAcquisitionIngestSource triple[] = {{paths[0], 0}, {paths[1], 0}, {paths[2], 0}};
Lardon3DAcquisitionMetadata tied[] = {metadata("TIE"), metadata("TIE"), metadata("TIE")};
lardon3d_acquisition_ingest_test_set_metadata(tied, 3);
Lardon3DAcquisitionIngestOutput ambiguous_output{};
CHECK(lardon3d_acquisition_ingest(&ambiguous.state, ambiguous.scanset.scanset_id, triple, 3,
&jpeg_options, &ambiguous_output) == LARDON3D_ACQUISITION_INGEST_OK);
CHECK(ambiguous_output.group_count == 3 && capture_count(ambiguous.database, ambiguous.scanset.scanset_id) == 3);
for (size_t i = 0; i < 3; ++i) CHECK(ambiguous_output.groups[i].basis == LARDON3D_ACQUISITION_GROUP_SINGLETON);
/* Resume is only the known Capture ID and converges image/source state. */
Fixture resumed;
CHECK(resumed.open());
char resume_file[4096]; CHECK(resumed.path(resume_file, "resume.JPG") && write_jpeg(resume_file, "resume"));
Lardon3DProjectDbCapture existing{};
CHECK(lardon3d_project_db_create_capture(resumed.database, resumed.scanset.scanset_id, 7, &existing) == LARDON3D_PROJECT_DB_OK);
Lardon3DAcquisitionIngestSource one[] = {{resume_file, 0}};
Lardon3DAcquisitionMetadata one_metadata[] = {metadata("RESUME")};
Lardon3DAcquisitionIngestOptions resume_options = options(); resume_options.resume_capture_id = existing.capture_id;
lardon3d_acquisition_ingest_test_set_metadata(one_metadata, 1);
Lardon3DAcquisitionIngestOutput first{};
CHECK(lardon3d_acquisition_ingest(&resumed.state, resumed.scanset.scanset_id, one, 1, &resume_options, &first) == LARDON3D_ACQUISITION_INGEST_OK && first.groups[0].capture_id == existing.capture_id);
Lardon3DAcquisitionIngestOutput retry{};
CHECK(lardon3d_acquisition_ingest(&resumed.state, resumed.scanset.scanset_id, one, 1, &resume_options, &retry) == LARDON3D_ACQUISITION_INGEST_OK && capture_count(resumed.database, resumed.scanset.scanset_id) == 1 && capture_assets(resumed.database, existing.capture_id, 1));
uint64_t images = 0; CHECK(lardon3d_project_db_count_images(resumed.database, resumed.scanset.scanset_id, &images) == LARDON3D_PROJECT_DB_OK && images == 1);
fixture.close(); strong.close(); ambiguous.close(); resumed.close();
return true;
}
bool test_explicit_and_db_helper() {
Fixture fixture;
CHECK(fixture.open());
char a[4096], b[4096], c[4096], helper_path[4096];
CHECK(fixture.path(a, "a.JPG") && fixture.path(b, "b.JPG") && fixture.path(c, "c.JPG") &&
fixture.path(helper_path, "helper.JPG") && write_jpeg(a, "a") && write_jpeg(b, "b") &&
write_jpeg(c, "c") && write_jpeg(helper_path, "helper"));
Lardon3DAcquisitionIngestSource inputs[] = {{a, 9}, {b, 9}, {c, 10}};
Lardon3DAcquisitionIngestOptions explicit_options = options();
explicit_options.grouping = LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT;
Lardon3DAcquisitionIngestOutput output{};
CHECK(lardon3d_acquisition_ingest(&fixture.state, fixture.scanset.scanset_id, inputs, 3,
&explicit_options, &output) == LARDON3D_ACQUISITION_INGEST_OK);
CHECK(output.group_count == 2 && output.groups[0].basis == LARDON3D_ACQUISITION_GROUP_EXPLICIT &&
output.groups[0].source_count == 2 && output.groups[1].basis == LARDON3D_ACQUISITION_GROUP_EXPLICIT);
Lardon3DProjectDbCapture capture{}, foreign{};
CHECK(lardon3d_project_db_create_capture(fixture.database, fixture.scanset.scanset_id, 8, &capture) == LARDON3D_PROJECT_DB_OK &&
lardon3d_project_db_create_capture(fixture.database, fixture.scanset.scanset_id, 9, &foreign) == LARDON3D_PROJECT_DB_OK);
Lardon3DProjectDbImageAsset asset{};
CHECK(lardon3d_image_catalog_publish_asset_file(&fixture.state, helper_path, 8, UINT64_MAX, &asset) == LARDON3D_IMAGE_CATALOG_ASSET_PUBLISHED);
Lardon3DProjectDbImageRegisterStatus status{}; Lardon3DProjectDbImage image{};
CHECK(lardon3d_project_db_publish_source_capture_image(fixture.database, capture.capture_id, asset.asset_id,
"helper.JPG", helper_path, 0, 8, false, &status, &image) == LARDON3D_PROJECT_DB_OK && status == LARDON3D_PROJECT_DB_IMAGE_REGISTERED);
uint64_t selected = 0;
CHECK(lardon3d_project_db_get_selected_capture_image(fixture.database, capture.capture_id, &selected) == LARDON3D_PROJECT_DB_NOT_FOUND);
CHECK(lardon3d_project_db_publish_source_capture_image(fixture.database, capture.capture_id, asset.asset_id,
"renamed-on-resume.JPG", "/relocated/source.JPG", 0, 99, true, &status, &image) == LARDON3D_PROJECT_DB_OK && status == LARDON3D_PROJECT_DB_IMAGE_ALREADY_PRESENT &&
lardon3d_project_db_get_selected_capture_image(fixture.database, capture.capture_id, &selected) == LARDON3D_PROJECT_DB_OK && selected == image.image_id);
CHECK(lardon3d_project_db_publish_source_capture_image(fixture.database, foreign.capture_id, asset.asset_id,
"helper.JPG", helper_path, 0, 8, false, &status, &image) == LARDON3D_PROJECT_DB_CONSTRAINT);
fixture.close();
return true;
}
} // namespace
int main() {
return test_ingest_grouping_and_resume() && test_explicit_and_db_helper() ? 0 : 1;
}