feat: add acquisition pairing evidence

This commit is contained in:
fy59 2026-08-27 11:31:38 +02:00
parent e6e7271094
commit 961d92fff3
5 changed files with 877 additions and 0 deletions

View file

@ -25,6 +25,29 @@ chaque image v18, relie son asset comme source et rend cette même image
sélectionnée. Aucun nom de fichier, EXIF ou rapprochement de siblings n'est
interprété ; les IDs et la sémantique v18 restent inchangés.
### S3-D — Acquisition Pairing Evidence v1
**IMPLEMENTED / VALIDATION PENDING — non FROZEN.** S3-D extrait, depuis des
assets source immuables, un ensemble fixe et borné de métadonnées d'évidence
d'appariement. Pour les RAW, l'extraction est metadata-only avec LibRaw ; pour
les JPEG, elle utilise libexif. Cette évidence n'est pas une identité
scientifique et ne modifie ni les identités existantes ni leur sémantique.
La politique d'appariement v1 est la suivante : entre deux assets distincts,
seul un `ImageUniqueID` caméra connu, non vide et exactement partagé est une
évidence forte, éligible à une association automatique dans une future
intégration. Les timestamp, make/model, numéro de série du boîtier concordant,
dimensions/exposition et basename sont faibles ou corroboratifs : ils ne
constituent jamais une identité. Des `ImageUniqueID` connus contradictoires, ou
des numéros de série de boîtier connus contradictoires, signifient que les
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.
## Phase H — décision Project DB v18
**PASS / FROZEN.** La migration transactionnelle v17→v18

View file

@ -0,0 +1,126 @@
#ifndef LARDON3D_ACQUISITION_PAIRING_H
#define LARDON3D_ACQUISITION_PAIRING_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION 1u
#define LARDON3D_ACQUISITION_TEXT_CAPACITY 128u
#define LARDON3D_ACQUISITION_DATETIME_CAPACITY 32u
#define LARDON3D_ACQUISITION_MAX_CANDIDATES 64u
typedef enum {
LARDON3D_ACQUISITION_SOURCE_UNSUPPORTED = 0,
LARDON3D_ACQUISITION_SOURCE_RAW = 1,
LARDON3D_ACQUISITION_SOURCE_JPEG = 2
} Lardon3DAcquisitionSourceKind;
typedef enum {
LARDON3D_ACQUISITION_OK = 0,
LARDON3D_ACQUISITION_INVALID_ARGUMENT,
LARDON3D_ACQUISITION_UNSUPPORTED_FORMAT,
LARDON3D_ACQUISITION_CORRUPT_SOURCE,
LARDON3D_ACQUISITION_IO_ERROR,
LARDON3D_ACQUISITION_METADATA_UNAVAILABLE,
LARDON3D_ACQUISITION_LIMIT_EXCEEDED,
LARDON3D_ACQUISITION_INTERNAL_ERROR
} Lardon3DAcquisitionResult;
enum {
LARDON3D_ACQUISITION_FIELD_MAKE = UINT32_C(1) << 0,
LARDON3D_ACQUISITION_FIELD_MODEL = UINT32_C(1) << 1,
LARDON3D_ACQUISITION_FIELD_BODY_SERIAL = UINT32_C(1) << 2,
LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL = UINT32_C(1) << 3,
LARDON3D_ACQUISITION_FIELD_SUBSEC_ORIGINAL = UINT32_C(1) << 4,
LARDON3D_ACQUISITION_FIELD_OFFSET_ORIGINAL = UINT32_C(1) << 5,
LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID = UINT32_C(1) << 6,
LARDON3D_ACQUISITION_FIELD_DIMENSIONS = UINT32_C(1) << 7,
LARDON3D_ACQUISITION_FIELD_ORIENTATION = UINT32_C(1) << 8
};
typedef struct {
uint32_t policy_version;
Lardon3DAcquisitionSourceKind source_kind;
uint32_t present_fields;
char make[LARDON3D_ACQUISITION_TEXT_CAPACITY];
char model[LARDON3D_ACQUISITION_TEXT_CAPACITY];
char body_serial[LARDON3D_ACQUISITION_TEXT_CAPACITY];
char datetime_original[LARDON3D_ACQUISITION_DATETIME_CAPACITY];
char subsec_original[LARDON3D_ACQUISITION_DATETIME_CAPACITY];
char offset_original[LARDON3D_ACQUISITION_DATETIME_CAPACITY];
char image_unique_id[LARDON3D_ACQUISITION_TEXT_CAPACITY];
uint32_t width;
uint32_t height;
uint16_t orientation;
uint16_t reserved;
} Lardon3DAcquisitionMetadata;
typedef enum {
LARDON3D_ACQUISITION_INSUFFICIENT = 0,
LARDON3D_ACQUISITION_SAME_ACQUISITION_CANDIDATE = 1,
LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG = 2,
LARDON3D_ACQUISITION_DIFFERENT = 3,
LARDON3D_ACQUISITION_AMBIGUOUS = 4
} Lardon3DAcquisitionDecision;
enum {
LARDON3D_ACQUISITION_EVIDENCE_IMAGE_UNIQUE_ID = UINT32_C(1) << 0,
LARDON3D_ACQUISITION_EVIDENCE_BODY_SERIAL = UINT32_C(1) << 1,
LARDON3D_ACQUISITION_EVIDENCE_MAKE = UINT32_C(1) << 2,
LARDON3D_ACQUISITION_EVIDENCE_MODEL = UINT32_C(1) << 3,
LARDON3D_ACQUISITION_EVIDENCE_DATETIME_SECOND = UINT32_C(1) << 4,
LARDON3D_ACQUISITION_EVIDENCE_DIMENSIONS = UINT32_C(1) << 5,
LARDON3D_ACQUISITION_EVIDENCE_BASENAME_HINT = UINT32_C(1) << 6
};
enum {
LARDON3D_ACQUISITION_CONTRADICTION_IMAGE_UNIQUE_ID = UINT32_C(1) << 0,
LARDON3D_ACQUISITION_CONTRADICTION_BODY_SERIAL = UINT32_C(1) << 1
};
typedef struct {
Lardon3DAcquisitionDecision decision;
uint32_t evidence;
uint32_t contradictions;
uint32_t strength;
} Lardon3DAcquisitionPairResult;
typedef struct {
uint64_t candidate_id;
const Lardon3DAcquisitionMetadata *metadata;
uint8_t same_asset;
uint8_t basename_hint;
uint8_t reserved[6];
} Lardon3DAcquisitionCandidate;
typedef struct {
Lardon3DAcquisitionDecision decision;
uint64_t selected_candidate_id;
size_t top_candidate_count;
Lardon3DAcquisitionPairResult selected_pair;
} Lardon3DAcquisitionSelection;
Lardon3DAcquisitionResult lardon3d_acquisition_extract_metadata(
const char *path, Lardon3DAcquisitionMetadata *metadata
);
Lardon3DAcquisitionResult lardon3d_acquisition_compare(
const Lardon3DAcquisitionMetadata *left, const Lardon3DAcquisitionMetadata *right,
int same_asset, int basename_hint, Lardon3DAcquisitionPairResult *result
);
Lardon3DAcquisitionResult lardon3d_acquisition_select_candidate(
const Lardon3DAcquisitionMetadata *source,
const Lardon3DAcquisitionCandidate *candidates, size_t candidate_count,
Lardon3DAcquisitionSelection *selection
);
#ifdef __cplusplus
}
#endif
#endif

View file

@ -37,6 +37,7 @@ opencv = dependency(
modules: ['opencv_core', 'opencv_imgcodecs', 'opencv_features2d', 'opencv_geometry'],
)
libraw = dependency('libraw', required: true)
libexif = dependency('libexif', required: true)
libpng = dependency('libpng', required: true)
libdeflate = dependency('libdeflate', required: true)
opencv_benchmark = dependency(
@ -148,6 +149,7 @@ executable(
'src/image_catalog.c',
'src/image_catalog_persistent.c',
'src/raw_development.cpp',
'src/acquisition_pairing.cpp',
'src/feature_extractor_opencv.cpp',
'src/feature_store.c',
'src/feature_extractor_opencv.cpp',
@ -198,6 +200,7 @@ executable(
sqlite3,
openssl,
libraw,
libexif,
libpng,
libdeflate,
opencv,
@ -320,6 +323,16 @@ raw_development_test = executable(
test('raw-development', raw_development_test, timeout: 30)
acquisition_pairing_test = executable(
'test-acquisition-pairing',
sources: ['tests/test_acquisition_pairing.cpp', 'src/acquisition_pairing.cpp'],
include_directories: include_directories('include'),
cpp_args: ['-DLARDON3D_ACQUISITION_PAIRING_TESTING'],
dependencies: [libraw, libexif],
)
test('acquisition-pairing', acquisition_pairing_test, timeout: 30)
feature_store_test = executable(
'test-feature-store',
sources: [

425
src/acquisition_pairing.cpp Normal file
View file

@ -0,0 +1,425 @@
#include <lardon3d/acquisition_pairing.h>
#include <libexif/exif-data.h>
#include <libexif/exif-utils.h>
#include <libraw/libraw.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <new>
namespace {
bool has_field(const Lardon3DAcquisitionMetadata &metadata, uint32_t field) {
return (metadata.present_fields & field) != 0u;
}
struct BoundedText {
const char *data;
size_t length;
bool available;
};
BoundedText bounded_text(const char *text, size_t capacity) {
const void *terminator = std::memchr(text, '\0', capacity);
if (terminator == nullptr || terminator == text) {
return {text, 0u, false};
}
const size_t length = static_cast<size_t>(static_cast<const char *>(terminator) - text);
return {text, length, true};
}
bool same_text(const char *left, size_t left_capacity, const char *right,
size_t right_capacity) {
const BoundedText left_text = bounded_text(left, left_capacity);
const BoundedText right_text = bounded_text(right, right_capacity);
return left_text.available && right_text.available &&
left_text.length == right_text.length &&
std::memcmp(left_text.data, right_text.data, left_text.length) == 0;
}
bool different_text(const char *left, size_t left_capacity, const char *right,
size_t right_capacity) {
const BoundedText left_text = bounded_text(left, left_capacity);
const BoundedText right_text = bounded_text(right, right_capacity);
return left_text.available && right_text.available &&
(left_text.length != right_text.length ||
std::memcmp(left_text.data, right_text.data, left_text.length) != 0);
}
bool copy_padded_text(char *destination, size_t capacity, const unsigned char *source,
size_t source_size) {
size_t length = source_size;
while (length > 0u && (source[length - 1u] == '\0' || source[length - 1u] == ' ')) {
--length;
}
if (length >= capacity) {
destination[0] = '\0';
return false;
}
if (length > 0u) {
std::memcpy(destination, source, length);
}
destination[length] = '\0';
return length != 0u;
}
void extract_raw_fixed_text(const libraw_data_t &raw,
Lardon3DAcquisitionMetadata &metadata) {
if (copy_padded_text(metadata.make, sizeof(metadata.make),
reinterpret_cast<const unsigned char *>(raw.idata.make),
sizeof(raw.idata.make))) {
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_MAKE;
}
if (copy_padded_text(metadata.model, sizeof(metadata.model),
reinterpret_cast<const unsigned char *>(raw.idata.model),
sizeof(raw.idata.model))) {
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_MODEL;
}
if (copy_padded_text(metadata.body_serial, sizeof(metadata.body_serial),
reinterpret_cast<const unsigned char *>(raw.shootinginfo.BodySerial),
sizeof(raw.shootinginfo.BodySerial))) {
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_BODY_SERIAL;
}
}
void extract_raw_image_unique_id(const libraw_data_t &raw,
Lardon3DAcquisitionMetadata &metadata) {
if (copy_padded_text(metadata.image_unique_id, sizeof(metadata.image_unique_id),
reinterpret_cast<const unsigned char *>(raw.color.ImageUniqueID),
sizeof(raw.color.ImageUniqueID))) {
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID;
}
}
ExifEntry *find_entry(ExifData *data, ExifTag tag) {
for (unsigned int index = 0u; index < EXIF_IFD_COUNT; ++index) {
ExifEntry *entry = exif_content_get_entry(data->ifd[index], tag);
if (entry != nullptr) {
return entry;
}
}
return nullptr;
}
void extract_ascii(ExifData *data, ExifTag tag, char *destination, size_t capacity,
uint32_t field, Lardon3DAcquisitionMetadata &metadata) {
ExifEntry *entry = find_entry(data, tag);
if (entry == nullptr || entry->data == nullptr || entry->size == 0u ||
entry->format != EXIF_FORMAT_ASCII) {
return;
}
if (copy_padded_text(destination, capacity, entry->data, entry->size)) {
metadata.present_fields |= field;
}
}
bool extract_unsigned(ExifData *data, ExifTag tag, uint32_t &value) {
ExifEntry *entry = find_entry(data, tag);
if (entry == nullptr || entry->data == nullptr) {
return false;
}
const ExifByteOrder order = exif_data_get_byte_order(data);
if (entry->format == EXIF_FORMAT_SHORT && entry->size >= 2u) {
value = exif_get_short(entry->data, order);
return true;
}
if (entry->format == EXIF_FORMAT_LONG && entry->size >= 4u) {
value = exif_get_long(entry->data, order);
return true;
}
return false;
}
Lardon3DAcquisitionResult extract_jpeg(const char *path,
Lardon3DAcquisitionMetadata &metadata) {
FILE *file = std::fopen(path, "rb");
if (file == nullptr) {
return errno == ENOENT || errno == EACCES ? LARDON3D_ACQUISITION_IO_ERROR
: LARDON3D_ACQUISITION_IO_ERROR;
}
bool has_end_marker = false;
if (std::fseek(file, -2L, SEEK_END) == 0) {
unsigned char tail[2] = {};
has_end_marker = std::fread(tail, 1u, sizeof(tail), file) == sizeof(tail) &&
tail[0] == 0xffu && tail[1] == 0xd9u;
}
std::fclose(file);
if (!has_end_marker) {
return LARDON3D_ACQUISITION_CORRUPT_SOURCE;
}
ExifData *data = exif_data_new_from_file(path);
if (data == nullptr) {
return LARDON3D_ACQUISITION_METADATA_UNAVAILABLE;
}
metadata.source_kind = LARDON3D_ACQUISITION_SOURCE_JPEG;
extract_ascii(data, EXIF_TAG_MAKE, metadata.make, sizeof(metadata.make),
LARDON3D_ACQUISITION_FIELD_MAKE, metadata);
extract_ascii(data, EXIF_TAG_MODEL, metadata.model, sizeof(metadata.model),
LARDON3D_ACQUISITION_FIELD_MODEL, metadata);
extract_ascii(data, EXIF_TAG_BODY_SERIAL_NUMBER, metadata.body_serial,
sizeof(metadata.body_serial), LARDON3D_ACQUISITION_FIELD_BODY_SERIAL,
metadata);
extract_ascii(data, EXIF_TAG_DATE_TIME_ORIGINAL, metadata.datetime_original,
sizeof(metadata.datetime_original),
LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL, metadata);
extract_ascii(data, EXIF_TAG_SUB_SEC_TIME_ORIGINAL, metadata.subsec_original,
sizeof(metadata.subsec_original), LARDON3D_ACQUISITION_FIELD_SUBSEC_ORIGINAL,
metadata);
extract_ascii(data, EXIF_TAG_OFFSET_TIME_ORIGINAL, metadata.offset_original,
sizeof(metadata.offset_original), LARDON3D_ACQUISITION_FIELD_OFFSET_ORIGINAL,
metadata);
extract_ascii(data, EXIF_TAG_IMAGE_UNIQUE_ID, metadata.image_unique_id,
sizeof(metadata.image_unique_id), LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID,
metadata);
uint32_t width = 0u;
uint32_t height = 0u;
if (extract_unsigned(data, EXIF_TAG_PIXEL_X_DIMENSION, width) &&
extract_unsigned(data, EXIF_TAG_PIXEL_Y_DIMENSION, height) && width != 0u &&
height != 0u) {
metadata.width = width;
metadata.height = height;
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_DIMENSIONS;
}
uint32_t orientation = 0u;
if (extract_unsigned(data, EXIF_TAG_ORIENTATION, orientation) && orientation <= UINT16_MAX) {
metadata.orientation = static_cast<uint16_t>(orientation);
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_ORIENTATION;
}
exif_data_unref(data);
return LARDON3D_ACQUISITION_OK;
}
Lardon3DAcquisitionResult extract_raw(const char *path,
Lardon3DAcquisitionMetadata &metadata) {
libraw_data_t *raw = libraw_init(0u);
if (raw == nullptr) {
return LARDON3D_ACQUISITION_INTERNAL_ERROR;
}
const int status = libraw_open_file(raw, path);
if (status != LIBRAW_SUCCESS) {
libraw_close(raw);
if (status == LIBRAW_FILE_UNSUPPORTED || status == LIBRAW_DATA_ERROR ||
status == LIBRAW_IO_ERROR) {
return LARDON3D_ACQUISITION_UNSUPPORTED_FORMAT;
}
return LARDON3D_ACQUISITION_CORRUPT_SOURCE;
}
metadata.source_kind = LARDON3D_ACQUISITION_SOURCE_RAW;
extract_raw_fixed_text(*raw, metadata);
extract_raw_image_unique_id(*raw, metadata);
if (raw->other.timestamp > 0) {
std::tm time_parts = {};
if (gmtime_r(&raw->other.timestamp, &time_parts) != nullptr &&
std::strftime(metadata.datetime_original, sizeof(metadata.datetime_original),
"%Y:%m:%d %H:%M:%S", &time_parts) != 0u) {
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL;
}
}
if (raw->sizes.width != 0u && raw->sizes.height != 0u) {
metadata.width = raw->sizes.width;
metadata.height = raw->sizes.height;
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_DIMENSIONS;
}
if (raw->sizes.flip >= 0 && raw->sizes.flip <= UINT16_MAX) {
metadata.orientation = static_cast<uint16_t>(raw->sizes.flip);
metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_ORIENTATION;
}
libraw_close(raw);
return LARDON3D_ACQUISITION_OK;
}
void record_matching_text(const Lardon3DAcquisitionMetadata &left,
const Lardon3DAcquisitionMetadata &right, uint32_t field,
const char *left_text, size_t left_capacity,
const char *right_text, size_t right_capacity, uint32_t evidence,
Lardon3DAcquisitionPairResult &result) {
if (has_field(left, field) && has_field(right, field) &&
same_text(left_text, left_capacity, right_text, right_capacity)) {
result.evidence |= evidence;
}
}
unsigned evidence_count(uint32_t value) {
unsigned count = 0u;
while (value != 0u) {
count += value & 1u;
value >>= 1u;
}
return count;
}
} // namespace
#ifdef LARDON3D_ACQUISITION_PAIRING_TESTING
extern "C" void lardon3d_acquisition_test_extract_raw_image_unique_id(
const libraw_data_t *raw, Lardon3DAcquisitionMetadata *metadata) {
if (raw != nullptr && metadata != nullptr) {
extract_raw_image_unique_id(*raw, *metadata);
}
}
extern "C" void lardon3d_acquisition_test_extract_raw_fixed_text(
const libraw_data_t *raw, Lardon3DAcquisitionMetadata *metadata) {
if (raw != nullptr && metadata != nullptr) {
extract_raw_fixed_text(*raw, *metadata);
}
}
#endif
extern "C" Lardon3DAcquisitionResult lardon3d_acquisition_extract_metadata(
const char *path, Lardon3DAcquisitionMetadata *metadata) {
if (path == nullptr || path[0] == '\0' || metadata == nullptr) {
return LARDON3D_ACQUISITION_INVALID_ARGUMENT;
}
try {
*metadata = {};
metadata->policy_version = LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION;
FILE *file = std::fopen(path, "rb");
if (file == nullptr) {
return LARDON3D_ACQUISITION_IO_ERROR;
}
unsigned char signature[2] = {};
const size_t bytes_read = std::fread(signature, 1u, sizeof(signature), file);
const bool read_error = std::ferror(file) != 0;
std::fclose(file);
if (read_error) {
return LARDON3D_ACQUISITION_IO_ERROR;
}
if (bytes_read == sizeof(signature) && signature[0] == 0xffu && signature[1] == 0xd8u) {
return extract_jpeg(path, *metadata);
}
return extract_raw(path, *metadata);
} catch (const std::bad_alloc &) {
return LARDON3D_ACQUISITION_INTERNAL_ERROR;
} catch (...) {
return LARDON3D_ACQUISITION_INTERNAL_ERROR;
}
}
extern "C" Lardon3DAcquisitionResult lardon3d_acquisition_compare(
const Lardon3DAcquisitionMetadata *left, const Lardon3DAcquisitionMetadata *right,
int same_asset, int basename_hint, Lardon3DAcquisitionPairResult *result) {
if (left == nullptr || right == nullptr || result == nullptr ||
left->policy_version != LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION ||
right->policy_version != LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION) {
return LARDON3D_ACQUISITION_INVALID_ARGUMENT;
}
*result = {};
result->decision = LARDON3D_ACQUISITION_INSUFFICIENT;
if (basename_hint != 0) {
result->evidence |= LARDON3D_ACQUISITION_EVIDENCE_BASENAME_HINT;
}
record_matching_text(*left, *right, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID,
left->image_unique_id, sizeof(left->image_unique_id),
right->image_unique_id, sizeof(right->image_unique_id),
LARDON3D_ACQUISITION_EVIDENCE_IMAGE_UNIQUE_ID, *result);
record_matching_text(*left, *right, LARDON3D_ACQUISITION_FIELD_BODY_SERIAL,
left->body_serial, sizeof(left->body_serial), right->body_serial,
sizeof(right->body_serial),
LARDON3D_ACQUISITION_EVIDENCE_BODY_SERIAL, *result);
record_matching_text(*left, *right, LARDON3D_ACQUISITION_FIELD_MAKE, left->make,
sizeof(left->make), right->make, sizeof(right->make),
LARDON3D_ACQUISITION_EVIDENCE_MAKE, *result);
record_matching_text(*left, *right, LARDON3D_ACQUISITION_FIELD_MODEL, left->model,
sizeof(left->model), right->model, sizeof(right->model),
LARDON3D_ACQUISITION_EVIDENCE_MODEL, *result);
record_matching_text(*left, *right, LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL,
left->datetime_original, sizeof(left->datetime_original),
right->datetime_original, sizeof(right->datetime_original),
LARDON3D_ACQUISITION_EVIDENCE_DATETIME_SECOND, *result);
if (has_field(*left, LARDON3D_ACQUISITION_FIELD_DIMENSIONS) &&
has_field(*right, LARDON3D_ACQUISITION_FIELD_DIMENSIONS) &&
left->width == right->width && left->height == right->height) {
result->evidence |= LARDON3D_ACQUISITION_EVIDENCE_DIMENSIONS;
}
if (has_field(*left, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID) &&
has_field(*right, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID) &&
different_text(left->image_unique_id, sizeof(left->image_unique_id),
right->image_unique_id, sizeof(right->image_unique_id))) {
result->contradictions |= LARDON3D_ACQUISITION_CONTRADICTION_IMAGE_UNIQUE_ID;
}
if (has_field(*left, LARDON3D_ACQUISITION_FIELD_BODY_SERIAL) &&
has_field(*right, LARDON3D_ACQUISITION_FIELD_BODY_SERIAL) &&
different_text(left->body_serial, sizeof(left->body_serial), right->body_serial,
sizeof(right->body_serial))) {
result->contradictions |= LARDON3D_ACQUISITION_CONTRADICTION_BODY_SERIAL;
}
if (same_asset != 0) {
result->strength = 0u;
return LARDON3D_ACQUISITION_OK;
}
if (result->contradictions != 0u) {
result->decision = LARDON3D_ACQUISITION_DIFFERENT;
return LARDON3D_ACQUISITION_OK;
}
if ((result->evidence & LARDON3D_ACQUISITION_EVIDENCE_IMAGE_UNIQUE_ID) != 0u) {
result->decision = LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG;
result->strength = 100u;
return LARDON3D_ACQUISITION_OK;
}
const uint32_t metadata_evidence =
result->evidence & ~LARDON3D_ACQUISITION_EVIDENCE_BASENAME_HINT;
if (metadata_evidence != 0u) {
result->decision = LARDON3D_ACQUISITION_SAME_ACQUISITION_CANDIDATE;
result->strength = evidence_count(metadata_evidence) +
((result->evidence & LARDON3D_ACQUISITION_EVIDENCE_BASENAME_HINT) != 0u
? 1u
: 0u);
}
return LARDON3D_ACQUISITION_OK;
}
extern "C" Lardon3DAcquisitionResult lardon3d_acquisition_select_candidate(
const Lardon3DAcquisitionMetadata *source,
const Lardon3DAcquisitionCandidate *candidates, size_t candidate_count,
Lardon3DAcquisitionSelection *selection) {
if (source == nullptr || selection == nullptr ||
(candidate_count != 0u && candidates == nullptr)) {
return LARDON3D_ACQUISITION_INVALID_ARGUMENT;
}
if (candidate_count > LARDON3D_ACQUISITION_MAX_CANDIDATES) {
return LARDON3D_ACQUISITION_LIMIT_EXCEEDED;
}
*selection = {};
selection->decision = LARDON3D_ACQUISITION_INSUFFICIENT;
uint32_t top_strength = 0u;
for (size_t index = 0u; index < candidate_count; ++index) {
if (candidates[index].metadata == nullptr) {
return LARDON3D_ACQUISITION_INVALID_ARGUMENT;
}
Lardon3DAcquisitionPairResult pair = {};
const Lardon3DAcquisitionResult status = lardon3d_acquisition_compare(
source, candidates[index].metadata, candidates[index].same_asset,
candidates[index].basename_hint, &pair);
if (status != LARDON3D_ACQUISITION_OK) {
return status;
}
if (pair.decision != LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG &&
pair.decision != LARDON3D_ACQUISITION_SAME_ACQUISITION_CANDIDATE) {
continue;
}
if (pair.strength > top_strength) {
top_strength = pair.strength;
selection->selected_candidate_id = candidates[index].candidate_id;
selection->selected_pair = pair;
selection->top_candidate_count = 1u;
} else if (pair.strength == top_strength) {
++selection->top_candidate_count;
}
}
if (selection->top_candidate_count == 0u) {
return LARDON3D_ACQUISITION_OK;
}
if (selection->top_candidate_count > 1u) {
selection->decision = LARDON3D_ACQUISITION_AMBIGUOUS;
selection->selected_candidate_id = 0u;
return LARDON3D_ACQUISITION_OK;
}
selection->decision = selection->selected_pair.decision;
return LARDON3D_ACQUISITION_OK;
}

View file

@ -0,0 +1,290 @@
#include <lardon3d/acquisition_pairing.h>
#include <libexif/exif-data.h>
#include <libraw/libraw.h>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <string>
#include <unistd.h>
namespace {
extern "C" void lardon3d_acquisition_test_extract_raw_image_unique_id(
const libraw_data_t *raw, Lardon3DAcquisitionMetadata *metadata);
extern "C" void lardon3d_acquisition_test_extract_raw_fixed_text(
const libraw_data_t *raw, Lardon3DAcquisitionMetadata *metadata);
Lardon3DAcquisitionMetadata metadata() {
Lardon3DAcquisitionMetadata value = {};
value.policy_version = LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION;
value.source_kind = LARDON3D_ACQUISITION_SOURCE_RAW;
return value;
}
void set_text(Lardon3DAcquisitionMetadata &value, uint32_t field, char *destination,
size_t capacity, const char *text) {
assert(std::strlen(text) < capacity);
std::strcpy(destination, text);
value.present_fields |= field;
}
Lardon3DAcquisitionPairResult compare(const Lardon3DAcquisitionMetadata &left,
const Lardon3DAcquisitionMetadata &right,
bool same_asset = false, bool basename_hint = false) {
Lardon3DAcquisitionPairResult result = {};
assert(lardon3d_acquisition_compare(&left, &right, same_asset, basename_hint, &result) ==
LARDON3D_ACQUISITION_OK);
return result;
}
void test_pairing_policy() {
auto left = metadata();
auto right = metadata();
set_text(left, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID, left.image_unique_id,
sizeof(left.image_unique_id), "UID-1");
set_text(right, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID, right.image_unique_id,
sizeof(right.image_unique_id), "UID-1");
assert(compare(left, right).decision == LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG);
auto result = compare(left, right, false, true);
assert(result.decision == LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG);
assert((result.evidence & LARDON3D_ACQUISITION_EVIDENCE_BASENAME_HINT) != 0u);
auto empty_left = metadata();
auto empty_right = metadata();
result = compare(empty_left, empty_right, false, true); /* DSC03350.ARW / DSC03350.JPG */
assert(result.decision == LARDON3D_ACQUISITION_INSUFFICIENT);
assert(result.evidence == LARDON3D_ACQUISITION_EVIDENCE_BASENAME_HINT);
set_text(empty_left, LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL,
empty_left.datetime_original, sizeof(empty_left.datetime_original),
"2026:08:27 12:34:56");
set_text(empty_right, LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL,
empty_right.datetime_original, sizeof(empty_right.datetime_original),
"2026:08:27 12:34:56");
assert(compare(empty_left, empty_right).decision ==
LARDON3D_ACQUISITION_SAME_ACQUISITION_CANDIDATE);
set_text(empty_left, LARDON3D_ACQUISITION_FIELD_MODEL, empty_left.model,
sizeof(empty_left.model), "ILCE-7M4");
set_text(empty_right, LARDON3D_ACQUISITION_FIELD_MODEL, empty_right.model,
sizeof(empty_right.model), "ILCE-7M4");
assert(compare(empty_left, empty_right).decision ==
LARDON3D_ACQUISITION_SAME_ACQUISITION_CANDIDATE);
std::strcpy(right.image_unique_id, "UID-2");
result = compare(left, right);
assert(result.decision == LARDON3D_ACQUISITION_DIFFERENT);
assert(result.contradictions ==
LARDON3D_ACQUISITION_CONTRADICTION_IMAGE_UNIQUE_ID);
auto serial_left = metadata();
auto serial_right = metadata();
set_text(serial_left, LARDON3D_ACQUISITION_FIELD_BODY_SERIAL, serial_left.body_serial,
sizeof(serial_left.body_serial), "BODY-A");
set_text(serial_right, LARDON3D_ACQUISITION_FIELD_BODY_SERIAL, serial_right.body_serial,
sizeof(serial_right.body_serial), "BODY-B");
assert(compare(serial_left, serial_right).decision == LARDON3D_ACQUISITION_DIFFERENT);
serial_right = metadata();
assert(compare(serial_left, serial_right).decision == LARDON3D_ACQUISITION_INSUFFICIENT);
auto other_device = metadata();
set_text(other_device, LARDON3D_ACQUISITION_FIELD_BODY_SERIAL, other_device.body_serial,
sizeof(other_device.body_serial), "BODY-B");
set_text(other_device, LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL,
other_device.datetime_original, sizeof(other_device.datetime_original),
"2026:08:27 12:34:56");
set_text(serial_left, LARDON3D_ACQUISITION_FIELD_DATETIME_ORIGINAL,
serial_left.datetime_original, sizeof(serial_left.datetime_original),
"2026:08:27 12:34:56");
assert(compare(serial_left, other_device, false, true).decision ==
LARDON3D_ACQUISITION_DIFFERENT);
assert(compare(left, left, true, true).decision == LARDON3D_ACQUISITION_INSUFFICIENT);
}
void test_selector() {
auto source = metadata();
auto candidate_b = metadata();
auto candidate_c = metadata();
set_text(source, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID, source.image_unique_id,
sizeof(source.image_unique_id), "SHARED");
set_text(candidate_b, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID,
candidate_b.image_unique_id, sizeof(candidate_b.image_unique_id), "SHARED");
set_text(candidate_c, LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID,
candidate_c.image_unique_id, sizeof(candidate_c.image_unique_id), "SHARED");
Lardon3DAcquisitionCandidate candidates[2] = {
{20u, &candidate_b, 0u, 0u, {}},
{30u, &candidate_c, 0u, 0u, {}},
};
Lardon3DAcquisitionSelection selection = {};
assert(lardon3d_acquisition_select_candidate(&source, candidates, 2u, &selection) ==
LARDON3D_ACQUISITION_OK);
assert(selection.decision == LARDON3D_ACQUISITION_AMBIGUOUS);
assert(selection.top_candidate_count == 2u);
assert(selection.selected_candidate_id == 0u);
const auto temporary = candidates[0];
candidates[0] = candidates[1];
candidates[1] = temporary;
assert(lardon3d_acquisition_select_candidate(&source, candidates, 2u, &selection) ==
LARDON3D_ACQUISITION_OK);
assert(selection.decision == LARDON3D_ACQUISITION_AMBIGUOUS);
assert(selection.top_candidate_count == 2u);
assert(selection.selected_candidate_id == 0u);
}
void test_non_terminated_public_text_is_unavailable() {
auto left = metadata();
auto right = metadata();
std::memset(left.image_unique_id, 'X', sizeof(left.image_unique_id));
std::memset(right.image_unique_id, 'X', sizeof(right.image_unique_id));
left.present_fields |= LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID;
right.present_fields |= LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID;
auto result = compare(left, right);
assert(result.decision == LARDON3D_ACQUISITION_INSUFFICIENT);
assert(result.evidence == 0u);
assert(result.contradictions == 0u);
std::memcpy(right.image_unique_id, "VALID", 6u);
result = compare(left, right);
assert(result.decision == LARDON3D_ACQUISITION_INSUFFICIENT);
assert(result.evidence == 0u);
assert(result.contradictions == 0u);
}
void add_ascii(ExifData *data, ExifIfd ifd, ExifTag tag, const char *value) {
ExifEntry *entry = exif_entry_new();
assert(entry != nullptr);
entry->tag = tag;
entry->format = EXIF_FORMAT_ASCII;
const size_t value_size = std::strlen(value) + 2u;
assert(value_size <= UINT32_MAX);
entry->components = static_cast<unsigned long>(value_size);
entry->size = static_cast<unsigned int>(value_size);
entry->data = static_cast<unsigned char *>(std::malloc(entry->size));
assert(entry->data != nullptr);
std::memcpy(entry->data, value, std::strlen(value));
entry->data[entry->size - 2u] = ' ';
entry->data[entry->size - 1u] = '\0';
exif_content_add_entry(data->ifd[ifd], entry);
exif_entry_unref(entry);
}
std::string temporary_path(const char *suffix) {
char path[] = "/tmp/lardon3d-acquisition-XXXXXX";
const int descriptor = mkstemp(path);
assert(descriptor >= 0);
close(descriptor);
std::string result(path);
result += suffix;
assert(std::rename(path, result.c_str()) == 0);
return result;
}
void write_exif_jpeg(const std::string &path) {
ExifData *data = exif_data_new();
assert(data != nullptr);
exif_data_set_byte_order(data, EXIF_BYTE_ORDER_INTEL);
add_ascii(data, EXIF_IFD_0, EXIF_TAG_MAKE, "Sony");
add_ascii(data, EXIF_IFD_0, EXIF_TAG_MODEL, "ILCE-7M4");
add_ascii(data, EXIF_IFD_EXIF, EXIF_TAG_DATE_TIME_ORIGINAL, "2026:08:27 12:34:56");
add_ascii(data, EXIF_IFD_EXIF, EXIF_TAG_SUB_SEC_TIME_ORIGINAL, "123");
add_ascii(data, EXIF_IFD_EXIF, EXIF_TAG_OFFSET_TIME_ORIGINAL, "+02:00");
add_ascii(data, EXIF_IFD_EXIF, EXIF_TAG_BODY_SERIAL_NUMBER, "BODY-42");
add_ascii(data, EXIF_IFD_EXIF, EXIF_TAG_IMAGE_UNIQUE_ID, "IMAGE-99");
unsigned char *encoded = nullptr;
unsigned int encoded_size = 0u;
exif_data_save_data(data, &encoded, &encoded_size);
assert(encoded != nullptr && encoded_size > 0u && encoded_size <= UINT16_MAX - 2u);
FILE *file = std::fopen(path.c_str(), "wb");
assert(file != nullptr);
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 & 0xffu)};
const unsigned char eoi[] = {0xffu, 0xd9u};
assert(std::fwrite(soi, 1u, sizeof(soi), file) == sizeof(soi));
assert(std::fwrite(length, 1u, sizeof(length), file) == sizeof(length));
assert(std::fwrite(encoded, 1u, encoded_size, file) == encoded_size);
assert(std::fwrite(eoi, 1u, sizeof(eoi), file) == sizeof(eoi));
assert(std::fclose(file) == 0);
std::free(encoded);
exif_data_unref(data);
}
void test_extraction() {
const std::string jpeg = temporary_path(".jpg");
write_exif_jpeg(jpeg);
Lardon3DAcquisitionMetadata value = {};
assert(lardon3d_acquisition_extract_metadata(jpeg.c_str(), &value) ==
LARDON3D_ACQUISITION_OK);
assert(value.source_kind == LARDON3D_ACQUISITION_SOURCE_JPEG);
assert(std::strcmp(value.make, "Sony") == 0);
assert(std::strcmp(value.model, "ILCE-7M4") == 0);
assert(std::strcmp(value.datetime_original, "2026:08:27 12:34:56") == 0);
assert(std::strcmp(value.subsec_original, "123") == 0);
assert(std::strcmp(value.offset_original, "+02:00") == 0);
assert(std::strcmp(value.body_serial, "BODY-42") == 0);
assert(std::strcmp(value.image_unique_id, "IMAGE-99") == 0);
assert(unlink(jpeg.c_str()) == 0);
const std::string malformed = temporary_path(".jpg");
FILE *file = std::fopen(malformed.c_str(), "wb");
assert(file != nullptr);
const unsigned char bad[] = {0xffu, 0xd8u, 0x00u};
assert(std::fwrite(bad, 1u, sizeof(bad), file) == sizeof(bad));
assert(std::fclose(file) == 0);
assert(lardon3d_acquisition_extract_metadata(malformed.c_str(), &value) ==
LARDON3D_ACQUISITION_CORRUPT_SOURCE);
assert(unlink(malformed.c_str()) == 0);
const std::string unsupported = temporary_path(".bin");
file = std::fopen(unsupported.c_str(), "wb");
assert(file != nullptr);
assert(std::fwrite("not an image", 1u, 12u, file) == 12u);
assert(std::fclose(file) == 0);
assert(lardon3d_acquisition_extract_metadata(unsupported.c_str(), &value) ==
LARDON3D_ACQUISITION_UNSUPPORTED_FORMAT);
assert(unlink(unsupported.c_str()) == 0);
}
void test_raw_image_unique_id_mapping() {
libraw_data_t raw = {};
Lardon3DAcquisitionMetadata value = {};
std::memcpy(raw.color.ImageUniqueID, "RAW-UID-42 ", 11u);
lardon3d_acquisition_test_extract_raw_image_unique_id(&raw, &value);
assert(std::strcmp(value.image_unique_id, "RAW-UID-42") == 0);
assert((value.present_fields & LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID) != 0u);
}
void test_non_terminated_raw_fixed_text_mapping() {
libraw_data_t raw = {};
Lardon3DAcquisitionMetadata value = {};
std::memset(raw.idata.make, 'M', sizeof(raw.idata.make));
std::memset(raw.idata.model, 'D', sizeof(raw.idata.model));
std::memset(raw.shootinginfo.BodySerial, 'S', sizeof(raw.shootinginfo.BodySerial));
lardon3d_acquisition_test_extract_raw_fixed_text(&raw, &value);
assert(std::strlen(value.make) == sizeof(raw.idata.make));
assert(std::strlen(value.model) == sizeof(raw.idata.model));
assert(std::strlen(value.body_serial) == sizeof(raw.shootinginfo.BodySerial));
assert((value.present_fields & LARDON3D_ACQUISITION_FIELD_MAKE) != 0u);
assert((value.present_fields & LARDON3D_ACQUISITION_FIELD_MODEL) != 0u);
assert((value.present_fields & LARDON3D_ACQUISITION_FIELD_BODY_SERIAL) != 0u);
}
} // namespace
int main() {
test_pairing_policy();
test_selector();
test_non_terminated_public_text_is_unavailable();
test_extraction();
test_raw_image_unique_id_mapping();
test_non_terminated_raw_fixed_text_mapping();
return 0;
}