diff --git a/gtsam/sfm/DsfTrackGenerator.cpp b/gtsam/sfm/DsfTrackGenerator.cpp new file mode 100644 index 000000000..e82880193 --- /dev/null +++ b/gtsam/sfm/DsfTrackGenerator.cpp @@ -0,0 +1,136 @@ +/* ---------------------------------------------------------------------------- + + * GTSAM Copyright 2010, Georgia Tech Research Corporation, + * Atlanta, Georgia 30332-0415 + * All Rights Reserved + * Authors: Frank Dellaert, et al. (see THANKS for the full author list) + + * See LICENSE for the license information + + * -------------------------------------------------------------------------- */ + +/** + * @file DsfTrackGenerator.cpp + * @date October 2022 + * @author John Lambert + * @brief Identifies connected components in the keypoint matches graph. + */ + +#include + +#include +#include + +namespace gtsam { + +namespace gtsfm { + +typedef DSFMap DSFMapIndexPair; + +/// Generate the DSF to form tracks. +static DSFMapIndexPair generateDSF(const MatchIndicesMap& matches) { + DSFMapIndexPair dsf; + + for (const auto& kv : matches) { + const auto pair_indices = kv.first; + const auto corr_indices = kv.second; + + // Image pair is (i1,i2). + size_t i1 = pair_indices.first; + size_t i2 = pair_indices.second; + for (size_t k = 0; k < corr_indices.rows(); k++) { + // Measurement indices are found in a single matrix row, as (k1,k2). + size_t k1 = corr_indices(k, 0), k2 = corr_indices(k, 1); + // Unique key for DSF is (i,k), representing keypoint index in an image. + dsf.merge({i1, k1}, {i2, k2}); + } + } + + return dsf; +} + +/// Generate a single track from a set of index pairs +static SfmTrack2d trackFromIndexPairs(const std::set& index_pair_set, + const KeypointsVector& keypoints) { + // Initialize track from measurements. + SfmTrack2d track2d; + + for (const auto& index_pair : index_pair_set) { + // Camera index is represented by i, and measurement index is + // represented by k. + size_t i = index_pair.i(); + size_t k = index_pair.j(); + // Add measurement to this track. + track2d.addMeasurement(i, keypoints[i].coordinates.row(k)); + } + + return track2d; +} + +/// Generate tracks from the DSF. +static std::vector tracksFromDSF(const DSFMapIndexPair& dsf, + const KeypointsVector& keypoints) { + const std::map > key_sets = dsf.sets(); + + // Return immediately if no sets were found. + if (key_sets.empty()) return {}; + + // Create a list of tracks. + // Each track will be represented as a list of (camera_idx, measurements). + std::vector tracks2d; + for (const auto& kv : key_sets) { + // Initialize track from measurements. + SfmTrack2d track2d = trackFromIndexPairs(kv.second, keypoints); + tracks2d.emplace_back(track2d); + } + return tracks2d; +} + +/** + * @brief Creates a list of tracks from 2d point correspondences. + * + * Creates a disjoint-set forest (DSF) and 2d tracks from pairwise matches. + * We create a singleton for union-find set elements from camera index of a + * detection and the index of that detection in that camera's keypoint list, + * i.e. (i,k). + * + * @param Map from (i1,i2) image pair indices to (K,2) matrix, for K + * correspondence indices, from each image. + * @param Length-N list of keypoints, for N images/cameras. + */ +std::vector tracksFromPairwiseMatches( + const MatchIndicesMap& matches, const KeypointsVector& keypoints, + bool verbose) { + // Generate the DSF to form tracks. + if (verbose) std::cout << "[SfmTrack2d] Starting Union-Find..." << std::endl; + DSFMapIndexPair dsf = generateDSF(matches); + if (verbose) std::cout << "[SfmTrack2d] Union-Find Complete" << std::endl; + + std::vector tracks2d = tracksFromDSF(dsf, keypoints); + + // Filter out erroneous tracks that had repeated measurements within the + // same image. This is an expected result from an incorrect correspondence + // slipping through. + std::vector validTracks; + std::copy_if( + tracks2d.begin(), tracks2d.end(), std::back_inserter(validTracks), + [](const SfmTrack2d& track2d) { return track2d.hasUniqueCameras(); }); + + if (verbose) { + size_t erroneous_track_count = tracks2d.size() - validTracks.size(); + double erroneous_percentage = static_cast(erroneous_track_count) / + static_cast(tracks2d.size()) * 100; + + std::cout << std::fixed << std::setprecision(2); + std::cout << "DSF Union-Find: " << erroneous_percentage; + std::cout << "% of tracks discarded from multiple obs. in a single image." + << std::endl; + } + + // TODO(johnwlambert): return the Transitivity failure percentage here. + return tracks2d; +} + +} // namespace gtsfm + +} // namespace gtsam diff --git a/gtsam/sfm/DsfTrackGenerator.h b/gtsam/sfm/DsfTrackGenerator.h new file mode 100644 index 000000000..14ec2302d --- /dev/null +++ b/gtsam/sfm/DsfTrackGenerator.h @@ -0,0 +1,78 @@ +/* ---------------------------------------------------------------------------- + + * GTSAM Copyright 2010, Georgia Tech Research Corporation, + * Atlanta, Georgia 30332-0415 + * All Rights Reserved + * Authors: Frank Dellaert, et al. (see THANKS for the full author list) + + * See LICENSE for the license information + + * -------------------------------------------------------------------------- */ + +/** + * @file DsfTrackGenerator.h + * @date July 2022 + * @author John Lambert + * @brief Identifies connected components in the keypoint matches graph. + */ + +#pragma once +#include +#include + +#include +#include +#include +#include + +namespace gtsam { + +namespace gtsfm { + +typedef Eigen::MatrixX2i CorrespondenceIndices; // N x 2 array + +// Output of detections in an image. +// Coordinate system convention: +// 1. The x coordinate denotes the horizontal direction (+ve direction towards +// the right). +// 2. The y coordinate denotes the vertical direction (+ve direction downwards). +// 3. Origin is at the top left corner of the image. +struct Keypoints { + // The (x, y) coordinates of the features, of shape Nx2. + Eigen::MatrixX2d coordinates; + + // Optional scale of the detections, of shape N. + // Note: gtsam::Vector is typedef'd for Eigen::VectorXd. + boost::optional scales; + + /// Optional confidences/responses for each detection, of shape N. + boost::optional responses; + + Keypoints(const Eigen::MatrixX2d& coordinates) + : coordinates(coordinates){}; // boost::none +}; + +using KeypointsVector = std::vector; +// Mapping from each image pair to (N,2) array representing indices of matching +// keypoints. +using MatchIndicesMap = std::map; + +/** + * @brief Creates a list of tracks from 2d point correspondences. + * + * Creates a disjoint-set forest (DSF) and 2d tracks from pairwise matches. + * We create a singleton for union-find set elements from camera index of a + * detection and the index of that detection in that camera's keypoint list, + * i.e. (i,k). + * + * @param Map from (i1,i2) image pair indices to (K,2) matrix, for K + * correspondence indices, from each image. + * @param Length-N list of keypoints, for N images/cameras. + */ +std::vector tracksFromPairwiseMatches( + const MatchIndicesMap& matches, const KeypointsVector& keypoints, + bool verbose = false); + +} // namespace gtsfm + +} // namespace gtsam diff --git a/gtsam/sfm/SfmTrack.h b/gtsam/sfm/SfmTrack.h index 37731b32a..c75199374 100644 --- a/gtsam/sfm/SfmTrack.h +++ b/gtsam/sfm/SfmTrack.h @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -35,28 +36,26 @@ typedef std::pair SfmMeasurement; typedef std::pair SiftIndex; /** - * @brief An SfmTrack stores SfM measurements grouped in a track - * @addtogroup sfm + * @brief Track containing 2D measurements associated with a single 3D point. + * Note: Equivalent to gtsam.SfmTrack, but without the 3d measurement. + * This class holds data temporarily before 3D point is initialized. */ -struct GTSAM_EXPORT SfmTrack { - Point3 p; ///< 3D position of the point - float r, g, b; ///< RGB color of the 3D point - +struct GTSAM_EXPORT SfmTrack2d { /// The 2D image projections (id,(u,v)) std::vector measurements; - /// The feature descriptors + /// The feature descriptors (optional) std::vector siftIndices; /// @name Constructors /// @{ - explicit SfmTrack(float r = 0, float g = 0, float b = 0) - : p(0, 0, 0), r(r), g(g), b(b) {} + // Default constructor. + SfmTrack2d() = default; - explicit SfmTrack(const gtsam::Point3& pt, float r = 0, float g = 0, - float b = 0) - : p(pt), r(r), g(g), b(b) {} + // Constructor from measurements. + explicit SfmTrack2d(const std::vector& measurements) + : measurements(measurements) {} /// @} /// @name Standard Interface @@ -78,6 +77,70 @@ struct GTSAM_EXPORT SfmTrack { /// Get the SIFT feature index corresponding to the measurement at `idx` const SiftIndex& siftIndex(size_t idx) const { return siftIndices[idx]; } + /** + * @brief Check that no two measurements are from the same camera. + * @returns boolean result of the validation. + */ + bool hasUniqueCameras() const { + std::vector track_cam_indices; + for (auto& measurement : measurements) { + track_cam_indices.emplace_back(measurement.first); + } + auto i = + std::adjacent_find(track_cam_indices.begin(), track_cam_indices.end()); + bool all_cameras_unique = (i == track_cam_indices.end()); + return all_cameras_unique; + } + + /// @} + /// @name Vectorized Interface + /// @{ + + /// @brief Return the measurements as a 2D matrix + Eigen::MatrixX2d measurementMatrix() const { + Eigen::MatrixX2d m(numberMeasurements(), 2); + for (size_t i = 0; i < numberMeasurements(); i++) { + m.row(i) = measurement(i).second; + } + return m; + } + + /// @brief Return the camera indices of the measurements + Eigen::VectorXi indexVector() const { + Eigen::VectorXi v(numberMeasurements()); + for (size_t i = 0; i < numberMeasurements(); i++) { + v(i) = measurement(i).first; + } + return v; + } + + /// @} +}; + +using SfmTrack2dVector = std::vector; + +/** + * @brief An SfmTrack stores SfM measurements grouped in a track + * @addtogroup sfm + */ +struct GTSAM_EXPORT SfmTrack : SfmTrack2d { + Point3 p; ///< 3D position of the point + float r, g, b; ///< RGB color of the 3D point + + /// @name Constructors + /// @{ + + explicit SfmTrack(float r = 0, float g = 0, float b = 0) + : p(0, 0, 0), r(r), g(g), b(b) {} + + explicit SfmTrack(const gtsam::Point3& pt, float r = 0, float g = 0, + float b = 0) + : p(pt), r(r), g(g), b(b) {} + + /// @} + /// @name Standard Interface + /// @{ + /// Get 3D point const Point3& point3() const { return p; } diff --git a/gtsam/sfm/sfm.i b/gtsam/sfm/sfm.i index 83bd07b13..26dc20c3e 100644 --- a/gtsam/sfm/sfm.i +++ b/gtsam/sfm/sfm.i @@ -4,10 +4,23 @@ namespace gtsam { -#include -#include #include -class SfmTrack { +class SfmTrack2d { + std::vector> measurements; + + SfmTrack2d(); + SfmTrack2d(const std::vector& measurements); + size_t numberMeasurements() const; + pair measurement(size_t idx) const; + pair siftIndex(size_t idx) const; + void addMeasurement(size_t idx, const gtsam::Point2& m); + gtsam::SfmMeasurement measurement(size_t idx) const; + bool hasUniqueCameras() const; + Eigen::MatrixX2d measurementMatrix() const; + Eigen::VectorXi indexVector() const; +}; + +virtual class SfmTrack : gtsam::SfmTrack2d { SfmTrack(); SfmTrack(const gtsam::Point3& pt); const Point3& point3() const; @@ -18,13 +31,6 @@ class SfmTrack { double g; double b; - std::vector> measurements; - - size_t numberMeasurements() const; - pair measurement(size_t idx) const; - pair siftIndex(size_t idx) const; - void addMeasurement(size_t idx, const gtsam::Point2& m); - // enabling serialization functionality void serialize() const; @@ -32,6 +38,8 @@ class SfmTrack { bool equals(const gtsam::SfmTrack& expected, double tol) const; }; +#include +#include #include class SfmData { SfmData(); @@ -115,7 +123,7 @@ class BinaryMeasurementsRot3 { #include -// TODO(frank): copy/pasta below until we have integer template paremeters in +// TODO(frank): copy/pasta below until we have integer template parameters in // wrap! class ShonanAveragingParameters2 { @@ -310,4 +318,38 @@ class TranslationRecovery { const gtsam::BinaryMeasurementsUnit3& relativeTranslations) const; }; +namespace gtsfm { + +#include + +class MatchIndicesMap { + MatchIndicesMap(); + MatchIndicesMap(const gtsam::gtsfm::MatchIndicesMap& other); + + size_t size() const; + bool empty() const; + void clear(); + gtsam::gtsfm::CorrespondenceIndices at(const pair& keypair) const; +}; + +class Keypoints { + Keypoints(const Eigen::MatrixX2d& coordinates); + Eigen::MatrixX2d coordinates; +}; + +class KeypointsVector { + KeypointsVector(); + KeypointsVector(const gtsam::gtsfm::KeypointsVector& other); + void push_back(const gtsam::gtsfm::Keypoints& keypoints); + size_t size() const; + bool empty() const; + void clear(); + gtsam::gtsfm::Keypoints at(const size_t& index) const; +}; + +gtsam::SfmTrack2dVector tracksFromPairwiseMatches( + const gtsam::gtsfm::MatchIndicesMap& matches_dict, + const gtsam::gtsfm::KeypointsVector& keypoints_list, bool verbose = false); +} // namespace gtsfm + } // namespace gtsam diff --git a/gtsam/sfm/tests/testSfmTrack.cpp b/gtsam/sfm/tests/testSfmTrack.cpp new file mode 100644 index 000000000..1b8c6bd9a --- /dev/null +++ b/gtsam/sfm/tests/testSfmTrack.cpp @@ -0,0 +1,53 @@ +/* ---------------------------------------------------------------------------- + + * GTSAM Copyright 2010, Georgia Tech Research Corporation, + * Atlanta, Georgia 30332-0415 + * All Rights Reserved + * Authors: Frank Dellaert, et al. (see THANKS for the full author list) + + * See LICENSE for the license information + + * -------------------------------------------------------------------------- */ + +/** + * @file TestSfmTrack.cpp + * @date October 2022 + * @author Frank Dellaert + * @brief tests for SfmTrack class + */ + +#include +#include +#include +#include + +using namespace std; +using namespace gtsam; + +/* ************************************************************************* */ +TEST(SfmTrack2d, defaultConstructor) { + SfmTrack2d track; + EXPECT_LONGS_EQUAL(0, track.measurements.size()); + EXPECT_LONGS_EQUAL(0, track.siftIndices.size()); +} + +/* ************************************************************************* */ +TEST(SfmTrack2d, measurementConstructor) { + SfmTrack2d track({{0, Point2(1, 2)}, {1, Point2(3, 4)}}); + EXPECT_LONGS_EQUAL(2, track.measurements.size()); + EXPECT_LONGS_EQUAL(0, track.siftIndices.size()); +} + +/* ************************************************************************* */ +TEST(SfmTrack, construction) { + SfmTrack track(Point3(1, 2, 3), 4, 5, 6); + EXPECT(assert_equal(Point3(1, 2, 3), track.point3())); + EXPECT(assert_equal(Point3(4, 5, 6), track.rgb())); +} + +/* ************************************************************************* */ +int main() { + TestResult tr; + return TestRegistry::runAllTests(tr); +} +/* ************************************************************************* */ diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 4457678d2..79a27f17f 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -51,7 +51,10 @@ set(ignore gtsam::BinaryMeasurementsUnit3 gtsam::BinaryMeasurementsRot3 gtsam::DiscreteKey - gtsam::KeyPairDoubleMap) + gtsam::KeyPairDoubleMap + gtsam::gtsfm::MatchIndicesMap + gtsam::gtsfm::KeypointsVector + gtsam::gtsfm::SfmTrack2dVector) set(interface_headers ${PROJECT_SOURCE_DIR}/gtsam/gtsam.i @@ -148,8 +151,12 @@ if(GTSAM_UNSTABLE_BUILD_PYTHON) gtsam::CameraSetCal3Bundler gtsam::CameraSetCal3Unified gtsam::CameraSetCal3Fisheye - gtsam::KeyPairDoubleMap) - + gtsam::KeyPairDoubleMap + gtsam::gtsfm::MatchIndicesMap + gtsam::gtsfm::KeypointsVector + gtsam::gtsfm::SfmTrack2dVector) + + pybind_wrap(${GTSAM_PYTHON_UNSTABLE_TARGET} # target ${PROJECT_SOURCE_DIR}/gtsam_unstable/gtsam_unstable.i # interface_header "gtsam_unstable.cpp" # generated_cpp diff --git a/python/gtsam/gtsfm.py b/python/gtsam/gtsfm.py new file mode 100644 index 000000000..afa709083 --- /dev/null +++ b/python/gtsam/gtsfm.py @@ -0,0 +1,4 @@ +# This trick is to allow direct import of sub-modules +# without this, we can only do `from gtsam.gtsam.gtsfm import X` +# with this trick, we can do `from gtsam.gtsfm import X` +from .gtsam.gtsfm import * \ No newline at end of file diff --git a/python/gtsam/preamble/sfm.h b/python/gtsam/preamble/sfm.h index 8ff0ea82e..27a4e5de9 100644 --- a/python/gtsam/preamble/sfm.h +++ b/python/gtsam/preamble/sfm.h @@ -15,12 +15,13 @@ // #include #include -PYBIND11_MAKE_OPAQUE( - std::vector); - -PYBIND11_MAKE_OPAQUE( - std::vector); +PYBIND11_MAKE_OPAQUE(std::vector); +PYBIND11_MAKE_OPAQUE(std::vector); +PYBIND11_MAKE_OPAQUE(std::vector); PYBIND11_MAKE_OPAQUE( std::vector>); PYBIND11_MAKE_OPAQUE( std::vector>); +PYBIND11_MAKE_OPAQUE( + std::vector); +PYBIND11_MAKE_OPAQUE(gtsam::gtsfm::MatchIndicesMap); \ No newline at end of file diff --git a/python/gtsam/specializations/sfm.h b/python/gtsam/specializations/sfm.h index 311b2c59b..c4817f555 100644 --- a/python/gtsam/specializations/sfm.h +++ b/python/gtsam/specializations/sfm.h @@ -18,16 +18,11 @@ py::bind_vector > >( py::bind_vector > >( m_, "BinaryMeasurementsRot3"); py::bind_map(m_, "KeyPairDoubleMap"); +py::bind_vector>(m_, "SfmTrack2dVector"); +py::bind_vector>(m_, "SfmTracks"); +py::bind_vector>(m_, "SfmCameras"); +py::bind_vector>>( + m_, "SfmMeasurementVector"); -py::bind_vector< - std::vector >( - m_, "SfmTracks"); - -py::bind_vector< - std::vector >( - m_, "SfmCameras"); - -py::bind_vector< - std::vector>>( - m_, "SfmMeasurementVector" - ); +py::bind_map(m_, "MatchIndicesMap"); +py::bind_vector>(m_, "KeypointsVector"); diff --git a/python/gtsam/tests/test_dsf_map.py b/python/gtsam/tests/test_DSFMap.py similarity index 88% rename from python/gtsam/tests/test_dsf_map.py rename to python/gtsam/tests/test_DSFMap.py index 6cae98ff5..f973f7c99 100644 --- a/python/gtsam/tests/test_dsf_map.py +++ b/python/gtsam/tests/test_DSFMap.py @@ -15,8 +15,7 @@ from __future__ import print_function import unittest from typing import Tuple -import gtsam -from gtsam import IndexPair +from gtsam import DSFMapIndexPair, IndexPair, IndexPairSetAsArray from gtsam.utils.test_case import GtsamTestCase @@ -29,10 +28,10 @@ class TestDSFMap(GtsamTestCase): def key(index_pair) -> Tuple[int, int]: return index_pair.i(), index_pair.j() - dsf = gtsam.DSFMapIndexPair() - pair1 = gtsam.IndexPair(1, 18) + dsf = DSFMapIndexPair() + pair1 = IndexPair(1, 18) self.assertEqual(key(dsf.find(pair1)), key(pair1)) - pair2 = gtsam.IndexPair(2, 2) + pair2 = IndexPair(2, 2) # testing the merge feature of dsf dsf.merge(pair1, pair2) @@ -45,7 +44,7 @@ class TestDSFMap(GtsamTestCase): k'th detected keypoint in image i. For the data below, merging such measurements into feature tracks across frames should create 2 distinct sets. """ - dsf = gtsam.DSFMapIndexPair() + dsf = DSFMapIndexPair() dsf.merge(IndexPair(0, 1), IndexPair(1, 2)) dsf.merge(IndexPair(0, 1), IndexPair(3, 4)) dsf.merge(IndexPair(4, 5), IndexPair(6, 8)) @@ -56,7 +55,7 @@ class TestDSFMap(GtsamTestCase): for i in sets: set_keys = [] s = sets[i] - for val in gtsam.IndexPairSetAsArray(s): + for val in IndexPairSetAsArray(s): set_keys.append((val.i(), val.j())) merged_sets.add(tuple(set_keys)) diff --git a/python/gtsam/tests/test_DsfTrackGenerator.py b/python/gtsam/tests/test_DsfTrackGenerator.py new file mode 100644 index 000000000..e600227c9 --- /dev/null +++ b/python/gtsam/tests/test_DsfTrackGenerator.py @@ -0,0 +1,96 @@ +"""Unit tests for track generation using a Disjoint Set Forest data structure. + +Authors: John Lambert +""" + +import unittest + +import gtsam +import numpy as np +from gtsam import IndexPair, KeypointsVector, MatchIndicesMap, Point2, SfmMeasurementVector, SfmTrack2d +from gtsam.gtsfm import Keypoints +from gtsam.utils.test_case import GtsamTestCase + + +class TestDsfTrackGenerator(GtsamTestCase): + """Tests for DsfTrackGenerator.""" + + def test_track_generation(self) -> None: + """Ensures that DSF generates three tracks from measurements + in 3 images (H=200,W=400).""" + kps_i0 = Keypoints(np.array([[10.0, 20], [30, 40]])) + kps_i1 = Keypoints(np.array([[50.0, 60], [70, 80], [90, 100]])) + kps_i2 = Keypoints(np.array([[110.0, 120], [130, 140]])) + + keypoints_list = KeypointsVector() + keypoints_list.append(kps_i0) + keypoints_list.append(kps_i1) + keypoints_list.append(kps_i2) + + # For each image pair (i1,i2), we provide a (K,2) matrix + # of corresponding image indices (k1,k2). + matches_dict = MatchIndicesMap() + matches_dict[IndexPair(0, 1)] = np.array([[0, 0], [1, 1]]) + matches_dict[IndexPair(1, 2)] = np.array([[2, 0], [1, 1]]) + + tracks = gtsam.gtsfm.tracksFromPairwiseMatches( + matches_dict, + keypoints_list, + verbose=False, + ) + assert len(tracks) == 3 + + # Verify track 0. + track0 = tracks[0] + assert track0.numberMeasurements() == 2 + np.testing.assert_allclose(track0.measurements[0][1], Point2(10, 20)) + np.testing.assert_allclose(track0.measurements[1][1], Point2(50, 60)) + assert track0.measurements[0][0] == 0 + assert track0.measurements[1][0] == 1 + np.testing.assert_allclose( + track0.measurementMatrix(), + [ + [10, 20], + [50, 60], + ], + ) + np.testing.assert_allclose(track0.indexVector(), [0, 1]) + + # Verify track 1. + track1 = tracks[1] + np.testing.assert_allclose( + track1.measurementMatrix(), + [ + [30, 40], + [70, 80], + [130, 140], + ], + ) + np.testing.assert_allclose(track1.indexVector(), [0, 1, 2]) + + # Verify track 2. + track2 = tracks[2] + np.testing.assert_allclose( + track2.measurementMatrix(), + [ + [90, 100], + [110, 120], + ], + ) + np.testing.assert_allclose(track2.indexVector(), [1, 2]) + + +class TestSfmTrack2d(GtsamTestCase): + """Tests for SfmTrack2d.""" + + def test_sfm_track_2d_constructor(self) -> None: + """ """ + measurements = SfmMeasurementVector() + measurements.append((0, Point2(10, 20))) + track = SfmTrack2d(measurements=measurements) + track.measurement(0) + track.numberMeasurements() == 1 + + +if __name__ == "__main__": + unittest.main()