From 961d92fff3bab9a7bd9e8f7e0f3d2eef409acb2e Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 27 Aug 2026 11:31:38 +0200 Subject: [PATCH] feat: add acquisition pairing evidence --- docs/architecture/project_database.md | 23 ++ include/lardon3d/acquisition_pairing.h | 126 ++++++++ meson.build | 13 + src/acquisition_pairing.cpp | 425 +++++++++++++++++++++++++ tests/test_acquisition_pairing.cpp | 290 +++++++++++++++++ 5 files changed, 877 insertions(+) create mode 100644 include/lardon3d/acquisition_pairing.h create mode 100644 src/acquisition_pairing.cpp create mode 100644 tests/test_acquisition_pairing.cpp diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index cdfda27..38354ed 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -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 diff --git a/include/lardon3d/acquisition_pairing.h b/include/lardon3d/acquisition_pairing.h new file mode 100644 index 0000000..8c8224c --- /dev/null +++ b/include/lardon3d/acquisition_pairing.h @@ -0,0 +1,126 @@ +#ifndef LARDON3D_ACQUISITION_PAIRING_H +#define LARDON3D_ACQUISITION_PAIRING_H + +#include +#include + +#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 diff --git a/meson.build b/meson.build index 0a0aec5..32b9844 100644 --- a/meson.build +++ b/meson.build @@ -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: [ diff --git a/src/acquisition_pairing.cpp b/src/acquisition_pairing.cpp new file mode 100644 index 0000000..0bbe2e7 --- /dev/null +++ b/src/acquisition_pairing.cpp @@ -0,0 +1,425 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +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(static_cast(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(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(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(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(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(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(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; +} diff --git a/tests/test_acquisition_pairing.cpp b/tests/test_acquisition_pairing.cpp new file mode 100644 index 0000000..a7343fa --- /dev/null +++ b/tests/test_acquisition_pairing.cpp @@ -0,0 +1,290 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +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(value_size); + entry->size = static_cast(value_size); + entry->data = static_cast(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(segment_size >> 8u), + static_cast(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; +}