diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d4246b..ff76c3f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ add_library(copick src/version.cpp src/util/escape.cpp src/models/validate.cpp + src/models/filament.cpp src/models/array.cpp src/io/json.cpp ) diff --git a/include/copick/copick.h b/include/copick/copick.h index da23bb1..54e0992 100644 --- a/include/copick/copick.h +++ b/include/copick/copick.h @@ -9,6 +9,7 @@ #include "copick/errors.h" #include "copick/escape.h" #include "copick/features.h" +#include "copick/filament.h" #include "copick/fwd.h" #include "copick/geometry.h" #include "copick/mesh.h" diff --git a/include/copick/filament.h b/include/copick/filament.h new file mode 100644 index 0000000..eb7ddcb --- /dev/null +++ b/include/copick/filament.h @@ -0,0 +1,35 @@ +// Filament objects: a pickable object whose metadata carries a filament spec under +// metadata["copick"]["filament"] (copick >= 1.28). Its picks are points along filaments, +// grouped by instance_id (the filament id, from 1; 0 = unassigned) and ordered along each +// filament. +// +// PUBLIC HEADER — must compile under -std=c++11. +#ifndef COPICK_FILAMENT_H +#define COPICK_FILAMENT_H + +#include "copick/export.h" +#include "copick/optional.h" +#include "copick/types.h" + +namespace copick { + +/// A filament object's spec. (copick.models.FilamentSpec) +struct FilamentSpec { + optional polar; ///< the structure has a polarity (microtubules: true) + optional helical_rise_a; ///< helical rise in angstrom, > 0 (descriptive) + optional helical_twist_deg; ///< helical twist in degrees (descriptive) +}; + +/// The filament spec of `obj`, or no value when `obj` is not a filament: its metadata has no +/// "copick" object, or that object's "filament" is absent or null. Unknown keys in the spec +/// are ignored. Throws ValidationError when the metadata is not valid JSON, or the spec is +/// not an object of the fields above, has helical_rise_a <= 0, or belongs to an object that +/// is not a particle — the cases copick refuses. +COPICK_API optional filament(const PickableObject& obj); + +/// Whether `obj` is a filament object (has a filament spec). Throws as filament(). +COPICK_API bool is_filament(const PickableObject& obj); + +} // namespace copick + +#endif // COPICK_FILAMENT_H diff --git a/include/copick/types.h b/include/copick/types.h index ef0b1b0..995e00f 100644 --- a/include/copick/types.h +++ b/include/copick/types.h @@ -10,6 +10,7 @@ #define COPICK_TYPES_H #include +#include #include #include @@ -43,10 +44,14 @@ struct Location { /// A picked point: location + orientation + score + instance id. /// (copick.models.CopickPoint) +/// +/// The particle centre is location + the transformation's translation (column 3), both in +/// angstrom in the tomogram frame. instance_id is 64-bit, as copick's Python int and JSON +/// integers are; for points along a filament it is the filament id (from 1; 0 = unassigned). struct Point { Location location; Matrix4 transformation = identity_matrix4(); - int instance_id = 0; + std::int64_t instance_id = 0; double score = 1.0; }; diff --git a/src/io/dto.hpp b/src/io/dto.hpp index 58a8558..51d9aa5 100644 --- a/src/io/dto.hpp +++ b/src/io/dto.hpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -145,7 +146,7 @@ struct LocationDTO { struct PointDTO { LocationDTO location; std::optional, 4>> transformation_; - std::optional instance_id; + std::optional instance_id; std::optional score; }; diff --git a/src/models/filament.cpp b/src/models/filament.cpp new file mode 100644 index 0000000..04e05b2 --- /dev/null +++ b/src/models/filament.cpp @@ -0,0 +1,70 @@ +#include "copick/filament.h" + +#include +#include + +#include +#include + +#include "copick/errors.h" + +namespace copick { +namespace { + +// The namespace and the spec are read as generic JSON first, so a "copick" value that is not +// an object (which copick leaves alone) is told apart from a malformed spec (which it refuses). +struct MetadataDTO { + std::optional copick; +}; + +struct NamespaceDTO { + std::optional filament; +}; + +struct FilamentSpecDTO { + std::optional polar; + std::optional helical_rise_a; + std::optional helical_twist_deg; +}; + +} // namespace + +optional filament(const PickableObject& obj) { + const std::string metadata = obj.metadata.empty() ? "{}" : obj.metadata; + const auto meta = rfl::json::read(metadata); + if (!meta) { + throw ValidationError("Invalid metadata of object '" + obj.name + "': " + meta.error().what()); + } + if (!meta.value().copick) return optional(); + const auto ns = rfl::json::read(rfl::json::write(*meta.value().copick)); + if (!ns || !ns.value().filament) return optional(); + + const std::string spec_json = rfl::json::write(*ns.value().filament); + if (spec_json == "null") return optional(); + const auto spec = rfl::json::read(spec_json); + if (!spec) { + throw ValidationError("Invalid filament spec of object '" + obj.name + + "': " + spec.error().what()); + } + if (!obj.is_particle) { + throw ValidationError("Object '" + obj.name + + "' has a filament spec but is not a particle (is_particle=false)."); + } + const FilamentSpecDTO& d = spec.value(); + if (d.helical_rise_a && !(*d.helical_rise_a > 0.0)) { + throw ValidationError("Filament spec of object '" + obj.name + + "': helical_rise_a must be > 0."); + } + + FilamentSpec out; + if (d.polar) out.polar = *d.polar; + if (d.helical_rise_a) out.helical_rise_a = *d.helical_rise_a; + if (d.helical_twist_deg) out.helical_twist_deg = *d.helical_twist_deg; + return out; +} + +bool is_filament(const PickableObject& obj) { + return filament(obj).has_value(); +} + +} // namespace copick diff --git a/tests/cpp/annotation_test.cpp b/tests/cpp/annotation_test.cpp index ad7a391..9613700 100644 --- a/tests/cpp/annotation_test.cpp +++ b/tests/cpp/annotation_test.cpp @@ -2,6 +2,7 @@ // Built only under COPICK_ENABLE_ZARR. #include +#include #include #include @@ -30,7 +31,7 @@ std::string config_for(const std::string& root_url) { R"(],"overlay_root":")" + root_url + R"("})"; } -copick::Point make_point(double x, double y, double z, int instance) { +copick::Point make_point(double x, double y, double z, std::int64_t instance) { copick::Point p; p.location.x = x; p.location.y = y; diff --git a/tests/cpp/json_test.cpp b/tests/cpp/json_test.cpp index 1fa5045..ccb9fb8 100644 --- a/tests/cpp/json_test.cpp +++ b/tests/cpp/json_test.cpp @@ -126,3 +126,22 @@ TEST(PicksJson, RoundTripKeepsTransformationKey) { EXPECT_DOUBLE_EQ(again.points[0].location.y, 200.0); EXPECT_DOUBLE_EQ(again.points[0].transformation[1][3], 20.0); } + +TEST(PicksJson, InstanceIdsAre64Bit) { + // Python ints are unbounded; a 32-bit field silently truncated ids >= 2^31. + const std::string json = R"JSON({ + "pickable_object_name": "microtubule", "user_id": "tracer", "session_id": "1", + "points": [ + {"location": {"x": 0.0, "y": 0.0, "z": 0.0}, "instance_id": 4294967296}, + {"location": {"x": 1.0, "y": 0.0, "z": 0.0}, "instance_id": 9007199254740993} + ] +})JSON"; + copick::CopickPicksFile f = copick::picks_from_json(json); + ASSERT_EQ(f.points.size(), 2u); + EXPECT_EQ(f.points[0].instance_id, INT64_C(4294967296)); + EXPECT_EQ(f.points[1].instance_id, INT64_C(9007199254740993)); + + copick::CopickPicksFile back = copick::picks_from_json(copick::picks_to_json(f)); + EXPECT_EQ(back.points[0].instance_id, INT64_C(4294967296)); + EXPECT_EQ(back.points[1].instance_id, INT64_C(9007199254740993)); +} diff --git a/tests/cpp/object_models_test.cpp b/tests/cpp/object_models_test.cpp index 360c26c..49c2a33 100644 --- a/tests/cpp/object_models_test.cpp +++ b/tests/cpp/object_models_test.cpp @@ -4,6 +4,7 @@ #include "copick/config.h" #include "copick/errors.h" +#include "copick/filament.h" #include "copick/types.h" #include "copick/validate.h" @@ -91,3 +92,44 @@ TEST(Point, LastRowValidation) { p.transformation[3] = {{1.0, 0.0, 0.0, 1.0}}; EXPECT_THROW(copick::validate(p), copick::ValidationError); } + +// --- Filament spec (metadata["copick"]["filament"]) ----------------------------- + +TEST(Filament, SpecIsReadFromMetadata) { + copick::PickableObject obj = make_valid(); + obj.metadata = + R"({"copick": {"filament": {"polar": true, "helical_rise_a": 82, "future_key": 1}}, "other": 2})"; + const copick::optional spec = copick::filament(obj); + ASSERT_TRUE(spec.has_value()); + const copick::FilamentSpec& s = *spec; + EXPECT_TRUE(s.polar.has_value() && *s.polar); + EXPECT_DOUBLE_EQ(*s.helical_rise_a, 82.0); + EXPECT_FALSE(s.helical_twist_deg.has_value()); + EXPECT_TRUE(copick::is_filament(obj)); + + obj.metadata = R"({"copick": {"filament": {}}})"; + EXPECT_TRUE(copick::is_filament(obj)); +} + +TEST(Filament, AbsentNullOrForeignNamespaceIsNotAFilament) { + copick::PickableObject obj = make_valid(); + for (const char* metadata : + {"{}", "", R"({"copick": {}})", R"({"copick": {"filament": null}})", R"({"copick": null})", + R"({"copick": "not ours"})", R"({"copick": [1, 2]})"}) { + obj.metadata = metadata; + EXPECT_FALSE(copick::is_filament(obj)) << metadata; + } +} + +TEST(Filament, InvalidSpecThrows) { + copick::PickableObject obj = make_valid(); + for (const char* metadata : + {R"({"copick": {"filament": true}})", R"({"copick": {"filament": {"polar": "yes"}}})", + R"({"copick": {"filament": {"helical_rise_a": 0}}})", "not json"}) { + obj.metadata = metadata; + EXPECT_THROW(copick::filament(obj), copick::ValidationError) << metadata; + } + obj.metadata = R"({"copick": {"filament": {}}})"; + obj.is_particle = false; + EXPECT_THROW(copick::filament(obj), copick::ValidationError); +} diff --git a/tests/header_guard/cxx11_include_all.cpp b/tests/header_guard/cxx11_include_all.cpp index 720a671..ccd6109 100644 --- a/tests/header_guard/cxx11_include_all.cpp +++ b/tests/header_guard/cxx11_include_all.cpp @@ -11,6 +11,7 @@ #include "copick/escape.h" #include "copick/export.h" #include "copick/features.h" +#include "copick/filament.h" #include "copick/fwd.h" #include "copick/geometry.h" #include "copick/mesh.h"