Merge "Build a basic rootfs for the rockpi 4b"
diff --git a/BUILD b/BUILD
index 9368a1f..4a4baa5 100644
--- a/BUILD
+++ b/BUILD
@@ -32,6 +32,8 @@
 # gazelle:resolve go github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule_response //scouting/webserver/requests/messages:request_shift_schedule_response_go_fbs
 # gazelle:resolve go github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule //scouting/webserver/requests/messages:submit_shift_schedule_go_fbs
 # gazelle:resolve go github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule_response //scouting/webserver/requests/messages:submit_shift_schedule_response_go_fbs
+# gazelle:resolve go github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking //scouting/webserver/requests/messages:submit_driver_ranking_go_fbs
+# gazelle:resolve go github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking_response //scouting/webserver/requests/messages:submit_driver_ranking_response_go_fbs
 
 gazelle(
     name = "gazelle",
diff --git a/WORKSPACE b/WORKSPACE
index 172e7bb..dbde661 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -56,6 +56,10 @@
     python_gtk_debs = "files",
 )
 load(
+    "//debian:gtk_runtime.bzl",
+    gtk_runtime_debs = "files",
+)
+load(
     "//debian:opencv_arm64.bzl",
     opencv_arm64_debs = "files",
 )
@@ -127,6 +131,8 @@
 
 generate_repositories_for_debs(python_gtk_debs)
 
+generate_repositories_for_debs(gtk_runtime_debs)
+
 generate_repositories_for_debs(opencv_arm64_debs)
 
 generate_repositories_for_debs(opencv_armhf_debs)
@@ -683,6 +689,13 @@
     url = "https://www.frc971.org/Build-Dependencies/python_gtk-4.tar.gz",
 )
 
+http_archive(
+    name = "gtk_runtime",
+    build_file = "@//debian:gtk_runtime.BUILD",
+    sha256 = "934693e64bfe63f0c55cdf432fe183eb077d6875d4d6a3dce4e47dbe7e20f5a9",
+    url = "https://www.frc971.org/Build-Dependencies/gtk_runtime-3.tar.gz",
+)
+
 # Downloaded from
 # https://developer.arm.com/-/media/Files/downloads/gnu-rm/7-2018q2/gcc-arm-none-eabi-7-2018-q2-update-linux.tar.bz2?revision=bc2c96c0-14b5-4bb4-9f18-bceb4050fee7?product=GNU%20Arm%20Embedded%20Toolchain,64-bit,,Linux,7-2018-q2-update
 http_archive(
diff --git a/aos/BUILD b/aos/BUILD
index 596996b..164f40b 100644
--- a/aos/BUILD
+++ b/aos/BUILD
@@ -514,6 +514,7 @@
         "configuration_test.cc",
     ],
     data = [
+        "//aos/events:ping_fbs_reflection_out",
         "//aos/events:pingpong_config",
         "//aos/events:pong_fbs_reflection_out",
         "//aos/testdata:test_configs",
@@ -521,6 +522,7 @@
     target_compatible_with = ["@platforms//os:linux"],
     deps = [
         ":configuration",
+        "//aos/events:ping_fbs",
         "//aos/testing:flatbuffer_eq",
         "//aos/testing:googletest",
         "//aos/testing:path",
diff --git a/aos/configuration.cc b/aos/configuration.cc
index d5ac2a1..ad6fb54 100644
--- a/aos/configuration.cc
+++ b/aos/configuration.cc
@@ -68,7 +68,6 @@
 }
 
 }  // namespace
-
 // Define the compare and equal operators for Channel and Application so we can
 // insert them in the btree below.
 bool operator<(const FlatbufferDetachedBuffer<Channel> &lhs,
@@ -1594,5 +1593,30 @@
   return channel->num_readers() + channel->num_senders();
 }
 
+// Searches through configurations for schemas that include a certain type
+const reflection::Schema *GetSchema(const Configuration *config,
+                                    std::string_view schema_type) {
+  if (config->has_channels()) {
+    std::vector<flatbuffers::Offset<Channel>> channel_offsets;
+    for (const Channel *c : *config->channels()) {
+      if (schema_type == c->type()->string_view()) {
+        return c->schema();
+      }
+    }
+  }
+  return nullptr;
+}
+
+// Copy schema reflection into detached flatbuffer
+std::optional<FlatbufferDetachedBuffer<reflection::Schema>>
+GetSchemaDetachedBuffer(const Configuration *config,
+                        std::string_view schema_type) {
+  const reflection::Schema *found_schema = GetSchema(config, schema_type);
+  if (found_schema == nullptr) {
+    return std::nullopt;
+  }
+  return RecursiveCopyFlatBuffer(found_schema);
+}
+
 }  // namespace configuration
 }  // namespace aos
diff --git a/aos/configuration.h b/aos/configuration.h
index 8515b18..4d84b23 100644
--- a/aos/configuration.h
+++ b/aos/configuration.h
@@ -212,7 +212,20 @@
 // Returns the number of scratch buffers in the queue.
 int QueueScratchBufferSize(const Channel *channel);
 
-// TODO(austin): GetSchema<T>(const Flatbuffer<Configuration> &config);
+// Searches through configurations for schemas that include a certain type.
+const reflection::Schema *GetSchema(const Configuration *config,
+                                    std::string_view schema_type);
+
+// GetSchema template
+template <typename T>
+const reflection::Schema *GetSchema(const Configuration *config) {
+  return GetSchema(config, T::GetFullyQualifiedName());
+}
+
+// Copy schema reflection into detached flatbuffer
+std::optional<FlatbufferDetachedBuffer<reflection::Schema>>
+GetSchemaDetachedBuffer(const Configuration *config,
+                        std::string_view schema_type);
 
 }  // namespace configuration
 
@@ -222,6 +235,7 @@
                const FlatbufferDetachedBuffer<Channel> &rhs);
 bool operator==(const FlatbufferDetachedBuffer<Channel> &lhs,
                 const FlatbufferDetachedBuffer<Channel> &rhs);
+
 }  // namespace aos
 
 #endif  // AOS_CONFIGURATION_H_
diff --git a/aos/configuration_test.cc b/aos/configuration_test.cc
index fa74e20..fc45d88 100644
--- a/aos/configuration_test.cc
+++ b/aos/configuration_test.cc
@@ -1,6 +1,7 @@
 #include "aos/configuration.h"
 
 #include "absl/strings/strip.h"
+#include "aos/events/ping_generated.h"
 #include "aos/json_to_flatbuffer.h"
 #include "aos/testing/flatbuffer_eq.h"
 #include "aos/testing/path.h"
@@ -1007,10 +1008,47 @@
       JsonToFlatbuffer<Channel>(
           "{ \"name\": \"/foo\", \"type\": \".aos.bar\", \"num_readers\": 5, "
           "\"num_senders\": 10 }");
-
   EXPECT_EQ(QueueScratchBufferSize(&channel.message()), 15);
 }
 
+// Tests that GetSchema returns schema of specified type
+TEST_F(ConfigurationTest, GetSchema) {
+  FlatbufferDetachedBuffer<Configuration> config =
+      ReadConfig(ArtifactPath("aos/events/pingpong_config.json"));
+  FlatbufferVector<reflection::Schema> expected_schema =
+      FileToFlatbuffer<reflection::Schema>(
+          ArtifactPath("aos/events/ping.bfbs"));
+  EXPECT_EQ(FlatbufferToJson(GetSchema(&config.message(), "aos.examples.Ping")),
+            FlatbufferToJson(expected_schema));
+  EXPECT_EQ(GetSchema(&config.message(), "invalid_name"), nullptr);
+}
+
+// Tests that GetSchema template returns schema of specified type
+TEST_F(ConfigurationTest, GetSchemaTemplate) {
+  FlatbufferDetachedBuffer<Configuration> config =
+      ReadConfig(ArtifactPath("aos/events/pingpong_config.json"));
+  FlatbufferVector<reflection::Schema> expected_schema =
+      FileToFlatbuffer<reflection::Schema>(
+          ArtifactPath("aos/events/ping.bfbs"));
+  EXPECT_EQ(FlatbufferToJson(GetSchema<aos::examples::Ping>(&config.message())),
+            FlatbufferToJson(expected_schema));
+}
+
+// Tests that GetSchemaDetachedBuffer returns detached buffer of specified type
+TEST_F(ConfigurationTest, GetSchemaDetachedBuffer) {
+  FlatbufferDetachedBuffer<Configuration> config =
+      ReadConfig(ArtifactPath("aos/events/pingpong_config.json"));
+  FlatbufferVector<reflection::Schema> expected_schema =
+      FileToFlatbuffer<reflection::Schema>(
+          ArtifactPath("aos/events/ping.bfbs"));
+  EXPECT_EQ(FlatbufferToJson(
+                GetSchemaDetachedBuffer(&config.message(), "aos.examples.Ping")
+                    .value()),
+            FlatbufferToJson(expected_schema));
+  EXPECT_EQ(GetSchemaDetachedBuffer(&config.message(), "invalid_name"),
+            std::nullopt);
+}
+
 }  // namespace testing
 }  // namespace configuration
 }  // namespace aos
diff --git a/aos/events/event_loop_runtime.rs b/aos/events/event_loop_runtime.rs
index 360c931..023cfb6 100644
--- a/aos/events/event_loop_runtime.rs
+++ b/aos/events/event_loop_runtime.rs
@@ -370,6 +370,9 @@
         MonotonicInstant(self.0.monotonic_now())
     }
 
+    pub fn realtime_now(&self) -> RealtimeInstant {
+        RealtimeInstant(self.0.realtime_now())
+    }
     /// Note that the `'event_loop` input lifetime is intentional. The C++ API requires that it is
     /// part of `self.configuration()`, which will always have this lifetime.
     ///
@@ -711,7 +714,6 @@
 where
     T: Follow<'a> + 'a;
 
-// TODO(Brian): Add the realtime timestamps here.
 impl<'a, T> TypedContext<'a, T>
 where
     T: Follow<'a> + 'a,
@@ -730,6 +732,12 @@
     pub fn monotonic_remote_time(&self) -> MonotonicInstant {
         self.0.monotonic_remote_time()
     }
+    pub fn realtime_event_time(&self) -> RealtimeInstant {
+        self.0.realtime_event_time()
+    }
+    pub fn realtime_remote_time(&self) -> RealtimeInstant {
+        self.0.realtime_remote_time()
+    }
     pub fn queue_index(&self) -> u32 {
         self.0.queue_index()
     }
@@ -750,10 +758,11 @@
     T::Inner: fmt::Debug,
 {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        // TODO(Brian): Add the realtime timestamps here.
         f.debug_struct("TypedContext")
             .field("monotonic_event_time", &self.monotonic_event_time())
             .field("monotonic_remote_time", &self.monotonic_remote_time())
+            .field("realtime_event_time", &self.realtime_event_time())
+            .field("realtime_remote_time", &self.realtime_remote_time())
             .field("queue_index", &self.queue_index())
             .field("remote_queue_index", &self.remote_queue_index())
             .field("message", &self.message())
@@ -1020,10 +1029,11 @@
 
 impl fmt::Debug for Context<'_> {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        // TODO(Brian): Add the realtime timestamps here.
         f.debug_struct("Context")
             .field("monotonic_event_time", &self.monotonic_event_time())
             .field("monotonic_remote_time", &self.monotonic_remote_time())
+            .field("realtime_event_time", &self.realtime_event_time())
+            .field("realtime_remote_time", &self.realtime_remote_time())
             .field("queue_index", &self.queue_index())
             .field("remote_queue_index", &self.remote_queue_index())
             .field("size", &self.data().map(|data| data.len()))
@@ -1033,7 +1043,6 @@
     }
 }
 
-// TODO(Brian): Add the realtime timestamps here.
 impl<'context> Context<'context> {
     pub fn monotonic_event_time(self) -> MonotonicInstant {
         MonotonicInstant(self.0.monotonic_event_time)
@@ -1043,6 +1052,14 @@
         MonotonicInstant(self.0.monotonic_remote_time)
     }
 
+    pub fn realtime_event_time(self) -> RealtimeInstant {
+        RealtimeInstant(self.0.realtime_event_time)
+    }
+
+    pub fn realtime_remote_time(self) -> RealtimeInstant {
+        RealtimeInstant(self.0.realtime_remote_time)
+    }
+
     pub fn queue_index(self) -> u32 {
         self.0.queue_index
     }
@@ -1093,9 +1110,6 @@
 /// Represents a `aos::monotonic_clock::time_point` in a natural Rust way. This
 /// is intended to have the same API as [`std::time::Instant`], any missing
 /// functionality can be added if useful.
-///
-/// TODO(Brian): Do RealtimeInstant too. Use a macro? Integer as a generic
-/// parameter to distinguish them? Or just copy/paste?
 #[repr(transparent)]
 #[derive(Clone, Copy, Eq, PartialEq)]
 pub struct MonotonicInstant(i64);
@@ -1125,6 +1139,34 @@
     }
 }
 
+#[repr(transparent)]
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub struct RealtimeInstant(i64);
+
+impl RealtimeInstant {
+    pub const MIN_TIME: Self = Self(i64::MIN);
+
+    pub fn is_min_time(self) -> bool {
+        self == Self::MIN_TIME
+    }
+
+    pub fn duration_since_epoch(self) -> Option<Duration> {
+        if self.is_min_time() {
+            None
+        } else {
+            Some(Duration::from_nanos(self.0.try_into().expect(
+                "monotonic_clock::time_point should always be after the epoch",
+            )))
+        }
+    }
+}
+
+impl fmt::Debug for RealtimeInstant {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        self.duration_since_epoch().fmt(f)
+    }
+}
+
 mod panic_waker {
     use std::task::{RawWaker, RawWakerVTable, Waker};
 
diff --git a/aos/util/file.cc b/aos/util/file.cc
index 317206e..cdf0061 100644
--- a/aos/util/file.cc
+++ b/aos/util/file.cc
@@ -36,7 +36,6 @@
 void WriteStringToFileOrDie(const std::string_view filename,
                             const std::string_view contents,
                             mode_t permissions) {
-  ::std::string r;
   ScopedFD fd(open(::std::string(filename).c_str(),
                    O_CREAT | O_WRONLY | O_TRUNC, permissions));
   PCHECK(fd.get() != -1) << ": opening " << filename;
diff --git a/debian/BUILD b/debian/BUILD
index de5144d..f14db44 100644
--- a/debian/BUILD
+++ b/debian/BUILD
@@ -51,6 +51,10 @@
     python_gtk_debs = "files",
 )
 load(
+    ":gtk_runtime.bzl",
+    gtk_runtime_debs = "files",
+)
+load(
     ":opencv_arm64.bzl",
     opencv_arm64_debs = "files",
 )
@@ -156,6 +160,33 @@
 )
 
 download_packages(
+    name = "download_gtk_runtime",
+    excludes = [
+        "libstdc++6",
+        "lsb-base",
+        "libglib2.0-dev-bin",
+        "fonts-freefont",
+        "gsettings-backend",
+        "libpng-dev",
+        "libz-dev",
+        "libstdc++-dev",
+        "libc6-dev",
+    ],
+    # Since "libglib2.0-0" pulls in glibc, we need to forcibly remove it again.
+    force_excludes = [
+        "libc6",
+        "libgcc-s1",
+    ],
+    force_includes = [
+        "libglib2.0-0",
+    ],
+    packages = [
+        "gir1.2-gtk-3.0",
+        "libgtk-3-dev",
+    ],
+)
+
+download_packages(
     name = "download_python_deps",
     excludes = [
         "libblas.so.3",
@@ -390,6 +421,12 @@
     target_compatible_with = ["@platforms//os:linux"],
 )
 
+generate_deb_tarball(
+    name = "gtk_runtime",
+    files = gtk_runtime_debs,
+    target_compatible_with = ["@platforms//os:linux"],
+)
+
 download_packages(
     name = "download_opencv",
     packages = [
diff --git a/debian/download_packages.py b/debian/download_packages.py
index 6d548b5..d012ff6 100755
--- a/debian/download_packages.py
+++ b/debian/download_packages.py
@@ -105,12 +105,15 @@
         yield package
 
 
-def download_deps(apt_args, packages, excludes, force_includes):
+def download_deps(apt_args, packages, excludes, force_includes,
+                  force_excludes):
     deps = get_all_deps(apt_args, packages)
     exclude_deps = get_all_deps(apt_args, excludes)
     deps -= exclude_deps
     force_include_deps = get_all_deps(apt_args, force_includes)
     deps |= force_include_deps
+    force_exclude_deps = get_all_deps(apt_args, force_excludes)
+    deps -= force_exclude_deps
     env = dict(os.environ)
     del env['LD_LIBRARY_PATH']
     subprocess.check_call([b"apt-get"] + [a.encode('utf-8')
@@ -173,6 +176,13 @@
         action="append",
         help=
         "Force include this and its dependencies. Even if listed in excludes.")
+    parser.add_argument(
+        "--force-exclude",
+        type=str,
+        action="append",
+        help=
+        "Force exclude this and its dependencies. Even if listed via --force-include."
+    )
     parser.add_argument("--arch",
                         type=str,
                         default="amd64",
@@ -214,7 +224,8 @@
     # Exclude common packages that don't make sense to include in everything all
     # the time.
     excludes += _ALWAYS_EXCLUDE
-    download_deps(apt_args, args.package, excludes, args.force_include)
+    download_deps(apt_args, args.package, excludes, args.force_include,
+                  args.force_exclude)
     fixup_files()
     print_file_list()
     print("Your packages are all in %s" % folder)
diff --git a/debian/gtk_runtime.BUILD b/debian/gtk_runtime.BUILD
new file mode 100644
index 0000000..e529782
--- /dev/null
+++ b/debian/gtk_runtime.BUILD
@@ -0,0 +1,12 @@
+filegroup(
+    name = "gtk_runtime",
+    srcs = glob([
+        "etc/**",
+        "lib/x86_64-linux-gnu/**/*.so*",
+        "usr/lib/**/*.so*",
+        "usr/lib/x86_64-linux-gnu/**/*",
+        "usr/share/font*/**",
+        "usr/share/gir-1.0/**",
+    ]),
+    visibility = ["//visibility:public"],
+)
diff --git a/debian/gtk_runtime.bzl b/debian/gtk_runtime.bzl
new file mode 100644
index 0000000..029419c
--- /dev/null
+++ b/debian/gtk_runtime.bzl
@@ -0,0 +1,267 @@
+files = {
+    "adwaita-icon-theme_3.38.0-1_all.deb": "2046876c82fc1c342b38ace9aa0661bcb3e167837c984b4bdc89702bc78df5ac",
+    "coreutils_8.32-4+b1_amd64.deb": "3558a412ab51eee4b60641327cb145bb91415f127769823b68f9335585b308d4",
+    "dconf-gsettings-backend_0.38.0-2_amd64.deb": "194991ed5f4ab1ca25413858cb99c910391cfd6d3b1b6a3d3e56a4b3a706a37d",
+    "dconf-service_0.38.0-2_amd64.deb": "639125f7a44d11f96661c61a07abbb58da0e5636ed406ac186adcef8651775c2",
+    "fontconfig-config_2.13.1-4.2_all.deb": "48afb6ad7d15e6104a343b789f73697301ad8bff77b69927bc998f5a409d8e90",
+    "fontconfig_2.13.1-4.2_amd64.deb": "c594a100759ef7c94149359cf4d2da5fb59ef30474c7a2dde1e049d32b9c478a",
+    "fonts-croscore_20201225-1_all.deb": "64904820b729ff40038f85683004e3b94b328d969bc0fbba263c58d635452923",
+    "fonts-dejavu-core_2.37-2_all.deb": "1f67421437b6eb18669d2868e3e02cb88668683d635198142f48aacc5b397118",
+    "fonts-freefont-otf_20120503-10_all.deb": "0b63996c80c6c660424af6d3832818e647960d6f65a51de010bb57dd0762faa7",
+    "fonts-freefont-ttf_20120503-10_all.deb": "4ca1c21ebc479198a3a5879d236c8317d6f7b2f1c403f7890e24c02eead05615",
+    "fonts-liberation2_2.1.3-1_all.deb": "e0805f0085132f5e6dd30f88c0d7260caf1e5450832fe2e3988a20fa9fa2150e",
+    "fonts-liberation_1.07.4-11_all.deb": "efd381517f958b01969343634ffcbdd60056be7779af84c6f53a005090430204",
+    "fonts-texgyre_20180621-3.1_all.deb": "cb7e9a4b2471cfdd57194c16364f9102f0639816a2662fed4b30d2a158747076",
+    "fonts-urw-base35_20200910-1_all.deb": "f95a139adb7f1b60626e76d4d45d1b35aad1bc2c2597394c291ef5f84b5dcb43",
+    "gir1.2-atk-1.0_2.36.0-2_amd64.deb": "36154c1e50e8e8013a14ce4ecfa1cf7527250beb49a2c60ac02ab2c8a40a5357",
+    "gir1.2-atspi-2.0_2.38.0-4_amd64.deb": "2b6f6d4c3de060e4f52cb7edba4c6ed9ab8d3601c4be617feac12e042df873ca",
+    "gir1.2-freedesktop_1.66.1-1+b1_amd64.deb": "60d8f35f0d67548088525543e3ff9e00934ebb5bfe7639afa45e5740e024f991",
+    "gir1.2-gdkpixbuf-2.0_2.42.2+dfsg-1+deb11u1_amd64.deb": "ba2552eb10b14b6f8ab44e28b7d638dd28de5b7e9a593e08b6f69395382a5c7b",
+    "gir1.2-glib-2.0_1.66.1-1+b1_amd64.deb": "1163a4e7eb095e37752739c0065bad50fa2177c13a87e7c1b0d44ed517fe8c91",
+    "gir1.2-gtk-3.0_3.24.24-4+deb11u2_amd64.deb": "a2a5c8e5aa3d8f7f5244464afce826485c96a0a3f2af8380fb33d8f2b2eb550d",
+    "gir1.2-harfbuzz-0.0_2.7.4-1_amd64.deb": "057b61d69437910e0350076cc0dd46d3ddb01ba181a434802aa328e81bc440d1",
+    "gir1.2-pango-1.0_1.46.2-3_amd64.deb": "0859356937e4b269201341ce410c77761fb68537ed3c317c223e7e67105ab0bb",
+    "glib-networking-common_2.66.0-2_all.deb": "a07370151ce5169e48ee7799b9bd9a7a035467a21f5cf3373b2aff090968609c",
+    "glib-networking-services_2.66.0-2_amd64.deb": "19131c7c31bc3fae604df30d2f73c3e8338ffeb2988fe167bb8b2b1c8913c9ab",
+    "glib-networking_2.66.0-2_amd64.deb": "b2cd50a8c3b30c16fd1a19c5244f681c6c0d1f426c385d44900477b052f70024",
+    "gsettings-desktop-schemas_3.38.0-2_all.deb": "3758968491a770e50cd85122c00d141944ffb43cb7a5c886a37290fef848cee3",
+    "gtk-update-icon-cache_3.24.24-4+deb11u2_amd64.deb": "b877617f382240663be1010510511a5f9fe10853a3f97088cc01be277ff184d6",
+    "hicolor-icon-theme_0.17-2_all.deb": "20304d34b85a734ec1e4830badf3a3a70a5dc5f9c1afc0b2230ecd760c81b5e0",
+    "icu-devtools_67.1-7_amd64.deb": "0a89d6f360d9c686c08d0156a0c8244715c9aaeffca079cf1716f12cffece82e",
+    "libatk-bridge2.0-0_2.38.0-1_amd64.deb": "65b063b4b45c5fd60d91e374d01bb73eacdb30c545a6ef0873d07d6da97765d1",
+    "libatk-bridge2.0-dev_2.38.0-1_amd64.deb": "04be11ea79e542a4eea20977e23557c5cc21427e93c354d69b86586f81d248c7",
+    "libatk1.0-0_2.36.0-2_amd64.deb": "572cd62f92ec25c75b98617321373d46a6717cbcc93d2025ebd6d550f1abf901",
+    "libatk1.0-data_2.36.0-2_all.deb": "86c1acae473977f8a78b905090847df654306996324493f9a39d9f27807778b2",
+    "libatk1.0-dev_2.36.0-2_amd64.deb": "8a107ce46427f5cf68076eb0ab7e9b09f0237cb2674499da582c5a29cfc94d72",
+    "libatspi2.0-0_2.38.0-4_amd64.deb": "53435278eb8815aafbb41db29a691a43a9de16fa58d9bc7908a1f6f2a07f0b67",
+    "libatspi2.0-dev_2.38.0-4_amd64.deb": "fbbb10ba97dbfc79c5c1edc223e606c792332a47d242b21b1dea5c9bae5dbc2c",
+    "libattr1_2.4.48-6_amd64.deb": "af3c3562eb2802481a2b9558df1b389f3c6d9b1bf3b4219e000e05131372ebaf",
+    "libavahi-client3_0.8-5+deb11u1_amd64.deb": "44104ae278d853f9d20b90a6192257497d430f3ff4af093af1c504effb9caf4f",
+    "libavahi-common-data_0.8-5+deb11u1_amd64.deb": "847c7050a234915514a967e2edbf8b1a02fe5451bb910f9bdeffda0688280fce",
+    "libavahi-common3_0.8-5+deb11u1_amd64.deb": "d5d97f84a894e6ef0e535a17d1dcc1ed64933d6e04a350306e989d05b37de00c",
+    "libblkid-dev_2.36.1-8+deb11u1_amd64.deb": "3f224b3dc4d094367b45b31c4bc367dd9528f45eba22af77229a7f9be7e6005d",
+    "libblkid1_2.36.1-8+deb11u1_amd64.deb": "9026ddd9f211008531ce6024d5ce042c723e237ecadfbf1f9343cb44aff492b9",
+    "libbrotli-dev_1.0.9-2+b2_amd64.deb": "520ef8f3af1a190ac2ce5954c0e42c8e6b80a593124f97e813be33e9e068ffc3",
+    "libbrotli1_1.0.9-2+b2_amd64.deb": "65ca7d8b03e9dac09c5d544a89dd52d1aeb74f6a19583d32e4ff5f0c77624c24",
+    "libbsd0_0.11.3-1_amd64.deb": "284a7b8dcfcad74770f57360721365317448b38ab773db542bf630e94e60c13e",
+    "libcairo-gobject2_1.16.0-5_amd64.deb": "a046d3ca805d4151029941fae736bfdf1c6f3dbcf1bd581102bd5ad844ea013e",
+    "libcairo-script-interpreter2_1.16.0-5_amd64.deb": "c1c47955283d36ccadbdfd88eef515063d28fdc20c751d70c863b18ca190ec8a",
+    "libcairo2-dev_1.16.0-5_amd64.deb": "a8ba01e9d19a1a4f512e7fa1ba1c089e2ace1a5b08733e167b9bea3fe86766de",
+    "libcairo2_1.16.0-5_amd64.deb": "b27210c0cf7757120e871abeba7de12a5cf94727a2360ecca5eb8e50ca809d12",
+    "libcolord2_1.4.5-3_amd64.deb": "b7f0b90535a04f25f4fe8a838b548eed87447b3225414bd4f30755ee917698dd",
+    "libcups2_2.3.3op2-3+deb11u2_amd64.deb": "67c6cf6ba6259468660da16676fcb7ac77cf4a14ca812a60375ca26263a7b273",
+    "libdatrie-dev_0.2.13-1_amd64.deb": "0885c9b6c0a448b1faaa5fa51f3b751b986f33e48ca98ae901413c22a4a6e5a3",
+    "libdatrie1_0.2.13-1_amd64.deb": "3544f2cf26039fade9c7e7297dde1458b8386442c3b0fc26fdf10127433341c1",
+    "libdbus-1-3_1.12.20-2_amd64.deb": "7256dfeda88461e6fccbf98372d3ec29487b3b2d0ae5d145a3332ab35274f0da",
+    "libdbus-1-dev_1.12.20-2_amd64.deb": "0bf0161cb23cf6d3adb3b7d5b701b982a65ad1ecff21e6267e69d803b1d88108",
+    "libdconf1_0.38.0-2_amd64.deb": "ff3b1d05466782acd6e335b001460b7af4ea76f49bbbbd5447535d2b702fa97e",
+    "libdeflate0_1.7-1_amd64.deb": "dadaf0d28360f6eb21ad389b2e0f12f8709c9de539b28de9c11d7ec7043dec95",
+    "libdpkg-perl_1.20.12_all.deb": "62b6da489682a684c8224a2cca0fc83d239846696cca5f67d5699c1df14b56ea",
+    "libdrm-amdgpu1_2.4.104-1_amd64.deb": "0005f21e342925bd26a25185289ae035aa931ced8f6fd9e3d4deade36d272ecd",
+    "libdrm-common_2.4.104-1_all.deb": "60c69026fb8e4cfdf8d80a4a86ee30516c611dcc4de4aa1c8ccbf06dff563e2b",
+    "libdrm-intel1_2.4.104-1_amd64.deb": "7d376adc7b5d4d83ec8414ff67dbc18765c6d420de9a6e1045fead7f1f82331d",
+    "libdrm-nouveau2_2.4.104-1_amd64.deb": "dbf4a3be55c609b1a2ea89d6782ae5c9a5b991844917dcd42c01666b73a96ceb",
+    "libdrm-radeon1_2.4.104-1_amd64.deb": "c33cd14e8ed7e2dfc02696ed51d4795c5797b0821666667e0a889bba705862b0",
+    "libdrm2_2.4.104-1_amd64.deb": "113396b3a33000f7f3347cd711ad9bcfe9945927331cc6cee63c751a889a967b",
+    "libedit2_3.1-20191231-2+b1_amd64.deb": "ac545f6ad10ba791aca24b09255ad1d6d943e6bc7c5511d5998e104aee51c943",
+    "libegl-dev_1.3.2-1_amd64.deb": "2847662b23487d5b1e467bca8cc8753baa880f794744a9b492c978bd5514b286",
+    "libegl-mesa0_20.3.5-1_amd64.deb": "a0c36a3665af89cbc96f865bd1b64c6c07b93096e91ba5b470d375d02dfa6d82",
+    "libegl1-mesa-dev_20.3.5-1_amd64.deb": "7b8139acb2e43a50fd952d54b41449baf13b404f65dccf187ae7852f028104f9",
+    "libegl1_1.3.2-1_amd64.deb": "3a5583ebd7a9d8ad102484db9637c409561428d21345037b310c4ef2ca8e8837",
+    "libelf1_0.183-1_amd64.deb": "e1ad132d502b255023c222d0cae1d02ca941f6b68fd0e9b908c6004cc326592c",
+    "libepoxy-dev_1.5.5-1_amd64.deb": "3979a7f81ffe10efcb1dcc3bd6e3ced5a88d1fe0ed68b12fb4cc4133b3e3d1b1",
+    "libepoxy0_1.5.5-1_amd64.deb": "3d050c9b138872c83b5b3521c97ab89f8a885b1391fdd0477cf8168ae54728a3",
+    "libexpat1-dev_2.2.10-2+deb11u4_amd64.deb": "fcf045732259c7303b8a2da0b2047e6823898cf3b0d7a40ece5dc6ad099a226a",
+    "libexpat1_2.2.10-2+deb11u4_amd64.deb": "d482f5d15353291e3a9e58c382d2ad3a412f028d3e553695a12f002c70b5a256",
+    "libffi-dev_3.3-6_amd64.deb": "ca2c71d9c68b1944b689606f12acf8023bad1b5083e8413894fd41ad0b977d20",
+    "libffi7_3.3-6_amd64.deb": "30ca89bfddae5fa6e0a2a044f22b6e50cd17c4bc6bc850c579819aeab7101f0f",
+    "libfontconfig-dev_2.13.1-4.2_amd64.deb": "7655d4238ee7e6ced13501006d20986cbf9ff08454a4e502d5aa399f83e28876",
+    "libfontconfig1-dev_2.13.1-4.2_amd64.deb": "a19502912fb57c1e9c87efbd7b7adad7f1c1b793164580ddf02168f0cfec59fb",
+    "libfontconfig1_2.13.1-4.2_amd64.deb": "b92861827627a76e74d6f447a5577d039ef2f95da18af1f29aa98fb96baea4c1",
+    "libfreetype-dev_2.10.4+dfsg-1+deb11u1_amd64.deb": "f0f5ece4c70fad68c8bdbe3cd09a5c9fb7d6112766f0d96439a45a9ae1aaf363",
+    "libfreetype6-dev_2.10.4+dfsg-1+deb11u1_amd64.deb": "ca1305fc7e3668f591ffc0e793569c7076dcf6d4765d8cf5cde7485fc7110beb",
+    "libfreetype6_2.10.4+dfsg-1+deb11u1_amd64.deb": "b21cfdd12adf6cac4af320c2485fb62a8a5edc6f9768bc2288fd686f4fa6dfdf",
+    "libfribidi-dev_1.0.8-2+deb11u1_amd64.deb": "b52de25f728d81b45440d5ad2458ca9809de700e8ee364320703d279e0ed4358",
+    "libfribidi0_1.0.8-2+deb11u1_amd64.deb": "690a889adfbe4e656e33b512dc1099cf29328632d684527dcfd4862810c5ee56",
+    "libgbm1_20.3.5-1_amd64.deb": "2d9b07282e46e3c9398613b6d4fe86c3259e4326b158be7e1f4f58cab541156c",
+    "libgcrypt20_1.8.7-6_amd64.deb": "7a2e0eef8e0c37f03f3a5fcf7102a2e3dc70ba987f696ab71949f9abf36f35ef",
+    "libgdk-pixbuf-2.0-0_2.42.2+dfsg-1+deb11u1_amd64.deb": "c593621089e9f8a8b5012de2cec9c835fdd64d0c42344423fc0a904c82e4967b",
+    "libgdk-pixbuf-2.0-dev_2.42.2+dfsg-1+deb11u1_amd64.deb": "9a0b3467e24b4369cffe154b55996d74270426b621748ae28044579c3a96e14b",
+    "libgdk-pixbuf-xlib-2.0-0_2.40.2-2_amd64.deb": "c11e9c92534e1e8036ad33a7ee1962b120834a02c41594cdf90ce01855ba84a4",
+    "libgdk-pixbuf-xlib-2.0-dev_2.40.2-2_amd64.deb": "5769f16c81c72ce50bd2aa8a7724b511d84ab411f9f23d5ef79b40fbd59c57e9",
+    "libgdk-pixbuf2.0-bin_2.42.2+dfsg-1+deb11u1_amd64.deb": "90ed931ec0abee05a6c8141ed6de98550f04c51a09500488fd5db1064437aac7",
+    "libgdk-pixbuf2.0-common_2.42.2+dfsg-1+deb11u1_all.deb": "e99738118ad4a63a4cfd7e34006fd379dd850b9527ec464a104b178a5038b5be",
+    "libgdk-pixbuf2.0-dev_2.40.2-2_amd64.deb": "98d1fe8b2fc224569a04d18f4a0efd6d5482feb47f85b1f4f3e149972a44a93e",
+    "libgirepository-1.0-1_1.66.1-1+b1_amd64.deb": "787e913bf56f19bc54720c3463ab8afe1cc9442536fde31e2a36afc3939f28c9",
+    "libgl-dev_1.3.2-1_amd64.deb": "a6487873f2706bbabf9346cdb190f47f23a1464f31cecf92c363bac37c342f2f",
+    "libgl1-mesa-dri_20.3.5-1_amd64.deb": "08e8bc20077e188da7061f77d23a336782d8463c0cc112fabbfa9c8b45923fd2",
+    "libgl1_1.3.2-1_amd64.deb": "f300f9610b5f05f1ce566c4095f1bf2170e512ac5d201c40d895b8fce29dec98",
+    "libglapi-mesa_20.3.5-1_amd64.deb": "aa8f8eaf13224cbb8729416be79350460f7f2230193b2da5d5e24f3dc7e9985f",
+    "libgles-dev_1.3.2-1_amd64.deb": "969e9197d8b8a36780f9b5d86f7c3066cdfef9dd7cdc3aee59a1870415c53578",
+    "libgles1_1.3.2-1_amd64.deb": "18425a2558be1de779c7c71ce780b133381f0db594a901839c6ae3d8e3f3c966",
+    "libgles2_1.3.2-1_amd64.deb": "367116f5e3b3a003a80203848b5ce1401451a67c2b2b9d6a383efc91badb0724",
+    "libglib2.0-0_2.66.8-1_amd64.deb": "995469490dcc8f667df8051a39dd5abd7149d849456c28af4e58cbfd6d6dc4f8",
+    "libglib2.0-bin_2.66.8-1_amd64.deb": "5adf4c916832ad4203fed68faacd4552361cbccc22f66f4504a7ad6fc955bddd",
+    "libglib2.0-data_2.66.8-1_all.deb": "be41a674336cefd00e2a468fe19c8bbf9f3fac86f39379e1b7acbad41f6af644",
+    "libglib2.0-dev_2.66.8-1_amd64.deb": "782fcfd549266048309b8da556377c16445bafe9f0aec31d9f246ac9b736d2aa",
+    "libglvnd-dev_1.3.2-1_amd64.deb": "e330ccbe6338789fd63212b55009dcce733265799395ad55b300cd1427234e7f",
+    "libglvnd0_1.3.2-1_amd64.deb": "52a4464d181949f5ed8f7e55cca67ba2739f019e93fcfa9d14e8d65efe98fffc",
+    "libglx-dev_1.3.2-1_amd64.deb": "5a50549948bc4363eab32b1083dad2165402c3628f2ee85e9a32563228cc61c1",
+    "libglx-mesa0_20.3.5-1_amd64.deb": "2d19e2addfbea965220e62f512318351f12bdfe7e180f265f00d0f2834a77833",
+    "libglx0_1.3.2-1_amd64.deb": "cb642200f7e28e6dbb4075110a0b441880eeec35c8a00a2198c59c53309e5e17",
+    "libgmp10_6.2.1+dfsg-1+deb11u1_amd64.deb": "fc117ccb084a98d25021f7e01e4dfedd414fa2118fdd1e27d2d801d7248aebbc",
+    "libgnutls30_3.7.1-5+deb11u2_amd64.deb": "ca2dbeb934a985f3ee1204f7a58001535ce49e8f8575d3fea08efbd4640773f1",
+    "libgpg-error0_1.38-2_amd64.deb": "16a507fb20cc58b5a524a0dc254a9cb1df02e1ce758a2d8abde0bc4a3c9b7c26",
+    "libgraphite2-3_1.3.14-1_amd64.deb": "31113b9e20c89d3b923da0540d6f30535b8d14f32e5904de89e34537fa87d59a",
+    "libgraphite2-dev_1.3.14-1_amd64.deb": "aa0437ff7c38b6e68a0bbcc3f18163677372e99fe3ec9673a552d8a8521aba64",
+    "libgtk-3-0_3.24.24-4+deb11u2_amd64.deb": "f58fcba87f2b7cb03a0f9f174817cc2ef18cd5dcfe41129b618ec3b7d5e0f8a0",
+    "libgtk-3-common_3.24.24-4+deb11u2_all.deb": "172d01f359af8f13cee93dba183e282ea5f059f2a418dfe66d35abf9dd60ddd7",
+    "libgtk-3-dev_3.24.24-4+deb11u2_amd64.deb": "24e7547f68a920c7d85ec38a102ba625e7f73d38ee2e7a494ba8b25eaf608062",
+    "libharfbuzz-dev_2.7.4-1_amd64.deb": "e2b5b9331990dc71da22fe05529cc72220e5449bcb98b08338c00e2f697cca65",
+    "libharfbuzz-gobject0_2.7.4-1_amd64.deb": "3c3cbf4150275173e7b4cdb0b12b8670867e70c27c0a31fd6559f4ce68a7dd84",
+    "libharfbuzz-icu0_2.7.4-1_amd64.deb": "43b41efde4c41c04b067f1d2917f33cb2d6a56b8e5d770e53ee71d7debdd241b",
+    "libharfbuzz0b_2.7.4-1_amd64.deb": "c76825341b5877240ff2511a376844a50ffda19d9d019ae65a5b3a97f9a1a183",
+    "libhogweed6_3.7.3-1_amd64.deb": "6aab2e892cdb2dfba45707601bc6c3b19aa228f70ae5841017f14c3b0ca3d22f",
+    "libice-dev_1.0.10-1_amd64.deb": "9d111d7e07104f7b9cc284d32e811ab01f376613f163b0580fbd7b61440ff669",
+    "libice6_1.0.10-1_amd64.deb": "452796e565c9d42386bd59990000ae9c37d85e142e00ee2b14df0787e2bbf970",
+    "libicu-dev_67.1-7_amd64.deb": "7932a6acfbfd76e1dbedcf171dafda9e549b8dc179a666043dbb3d5b733c4a29",
+    "libicu67_67.1-7_amd64.deb": "2bf5c46254f527865bfd6368e1120908755fa57d83634bd7d316c9b3cfd57303",
+    "libidn2-0_2.3.0-5_amd64.deb": "cb80cd769171537bafbb4a16c12ec427065795946b3415781bc9792e92d60b59",
+    "libjbig0_2.1-3.1+b2_amd64.deb": "9646d69eefce505407bf0437ea12fb7c2d47a3fd4434720ba46b642b6dcfd80f",
+    "libjpeg62-turbo_2.0.6-4_amd64.deb": "28de780a1605cf501c3a4ebf3e588f5110e814b208548748ab064100c32202ea",
+    "libjson-glib-1.0-0_1.6.2-1_amd64.deb": "c2db69dda6ceda43065d694c5ebd515900dd38d7231a74016f10a2d2a870f01d",
+    "libjson-glib-1.0-common_1.6.2-1_all.deb": "a938ec35a20dca2e5878a8750fb44683b67a5f7c2d23d383963803a9fcfac1a3",
+    "liblcms2-2_2.12~rc1-2_amd64.deb": "0608ecb6ed258814e390b52b3fb50f2a6d3239b5ecb1086292ae08be00a67b0f",
+    "libllvm11_11.0.1-2_amd64.deb": "eaff3c8dd6039af90b8b6bdbf33433e35d8c808a7aa195d0e3800ef5e61affff",
+    "liblz4-1_1.9.3-2_amd64.deb": "79ac6e9ca19c483f2e8effcc3401d723dd9dbb3a4ae324714de802adb21a8117",
+    "liblzo2-2_2.10-2_amd64.deb": "4f08e092c76e425295a498cd547dc9b8f6a595473f3020ab8c96309b29872636",
+    "libmd0_1.0.3-3_amd64.deb": "9e425b3c128b69126d95e61998e1b5ef74e862dd1fc953d91eebcc315aea62ea",
+    "libmount-dev_2.36.1-8+deb11u1_amd64.deb": "e2ab59f02398ff5f50d58ba5702a3dc27d47b6b028fccab03d0e8060e317f328",
+    "libmount1_2.36.1-8+deb11u1_amd64.deb": "a3d8673804f32e9716e33111714e250b6f1092770a52e21fab99d0ab4b48c5d9",
+    "libnettle8_3.7.3-1_amd64.deb": "e4f8ec31ed14518b241eb7b423ad5ed3f4a4e8ac50aae72c9fd475c569582764",
+    "libopengl-dev_1.3.2-1_amd64.deb": "7e598e73830ffb5d6fae58ebd1c769b6f7806dc92bd5649893b74f1302b47e82",
+    "libopengl0_1.3.2-1_amd64.deb": "4327a9f20b88e7bcb07af3b196121096877331b61eeed64467854eb0b525fc43",
+    "libp11-kit0_0.23.22-1_amd64.deb": "bfef5f31ee1c730e56e16bb62cc5ff8372185106c75bf1ed1756c96703019457",
+    "libpango-1.0-0_1.46.2-3_amd64.deb": "cfb3079a7397cc7d50eabe28ea70ce15ba371c84efafd8f8529ee047e667f523",
+    "libpango1.0-dev_1.46.2-3_amd64.deb": "5da5a8009ab6275b12193e277bf6d091b29203f058d246d5b8a184d1c7f0cde6",
+    "libpangocairo-1.0-0_1.46.2-3_amd64.deb": "f0489372e4bcb153d750934eb3cddd9104bc3a46d564aa10bef320ba89681d37",
+    "libpangoft2-1.0-0_1.46.2-3_amd64.deb": "78067d7222459902e22da6b4c1ab8ee84940752d25a5f3dea1a43f846a8562e3",
+    "libpangoxft-1.0-0_1.46.2-3_amd64.deb": "621545808843e84288039a55df16023ff872f48b3a155788dba7a1cea25c7a9b",
+    "libpciaccess0_0.16-1_amd64.deb": "f581ced157bd475477337860e7e7fcabeeb091444bc5a189c5c97adc8fcabda5",
+    "libpcre16-3_8.39-13_amd64.deb": "04ef146b0119a8a5ab1df09d990bd61a45bf99d2989aa248ebc7f72dbb99544e",
+    "libpcre2-16-0_10.36-2+deb11u1_amd64.deb": "386fc5684d0339469f0910aefc96f12d2b058dc22d096605f483a56475a37d39",
+    "libpcre2-32-0_10.36-2+deb11u1_amd64.deb": "b6d6b388adb390aae0690c0398b813222b0ed16a1705b8fb2acd1d190c03936a",
+    "libpcre2-8-0_10.36-2+deb11u1_amd64.deb": "ee192c8d22624eb9d0a2ae95056bad7fb371e5abc17e23e16b1de3ddb17a1064",
+    "libpcre2-dev_10.36-2+deb11u1_amd64.deb": "bd4bf9a13dc86c14b6ed8d822d5c8eb66b80418fe8a0484fe1cd6836a8381c49",
+    "libpcre2-posix2_10.36-2+deb11u1_amd64.deb": "f19dc0b4145836eb0c5ce462e16f546fb5298b9186d760d829cd0c171d0a2afd",
+    "libpcre3-dev_8.39-13_amd64.deb": "e588a2bd07e2770ad2fa9e3b02e359d3ff3c6f0c17a809365d3e97da7b0e64e0",
+    "libpcre32-3_8.39-13_amd64.deb": "961135f3ff2d00c2e46640b9730d9ddef80ae9d9037e2ec882ee8f6ce5dd48c9",
+    "libpcre3_8.39-13_amd64.deb": "48efcf2348967c211cd9408539edf7ec3fa9d800b33041f6511ccaecc1ffa9d0",
+    "libpcrecpp0v5_8.39-13_amd64.deb": "79e15b8d31f8561ad1c19f8c280d0a9fe280f7872701ef53c9bdfce6b3015a18",
+    "libpixman-1-0_0.40.0-1_amd64.deb": "55236a7d4b9db107eb480ac56b3aa786572ea577ba34323baf46aceb7ba6d012",
+    "libpixman-1-dev_0.40.0-1_amd64.deb": "bcde62aee0fe759798e8a4d3a3d9b0666ba5ab15d1cb9e69fa000ff23ba305cb",
+    "libproxy1v5_0.4.17-1_amd64.deb": "b21c1524b972dd72387ecb8b12c0a860738ce0832ed18fe7ffb9da6adc9b9e41",
+    "libpsl5_0.21.0-1.2_amd64.deb": "d716f5b4346ec85bb728f4530abeb1da4a79f696c72d7f774c59ba127c202fa7",
+    "libpthread-stubs0-dev_0.4-1_amd64.deb": "54632f160e1e8a43656a87195a547391038c4ca0f53291b849cd4457ba5dfde9",
+    "librest-0.7-0_0.8.1-1.1_amd64.deb": "5cd57a96145a362bf60428315ab3fc6c2f528ab38a06a905da2568575c23bdc8",
+    "libselinux1-dev_3.1-3_amd64.deb": "16b14d7e8ed88b9b07d1b52d84d04ab2fcdfcdc4b8cecc9dd34df06f3ce7d3fb",
+    "libselinux1_3.1-3_amd64.deb": "339f5ede10500c16dd7192d73169c31c4b27ab12130347275f23044ec8c7d897",
+    "libsensors-config_3.6.0-7_all.deb": "4265811140a591d27c99d026b63707d8235d98c73d7543c66ab9ec73c28523fc",
+    "libsensors5_3.6.0-7_amd64.deb": "b9cb9a081ea3c9b68ef047d7e51f3b84bccde1a2467d5657df4c5d54775b187e",
+    "libsepol1-dev_3.1-1_amd64.deb": "1bec586de489db87c8746a6eeed27982915fc578c91e9e78ef39773ab824e023",
+    "libsepol1_3.1-1_amd64.deb": "b6057dc6806a6dfaef74b09d84d1f18716d7a6d2f1da30520cef555210c6af62",
+    "libsm-dev_1.2.3-1_amd64.deb": "2ff8641d3217dc1a0f26514f5d8de2009669423a4aa0db46b3df564a8b367026",
+    "libsm6_1.2.3-1_amd64.deb": "22a420890489023346f30fecef14ea900a0788e7bf959ef826aabb83944fccfb",
+    "libsoup-gnome2.4-1_2.72.0-2_amd64.deb": "7fdc774b567e3a5e0881aa01fcfcac637fdeeb8ea6233b710571e1f5b3a994b6",
+    "libsoup2.4-1_2.72.0-2_amd64.deb": "32dad5305be0faa619df36688a20d187ba915f02e9e184cc5c3c6e3d98259e9c",
+    "libsqlite3-0_3.34.1-3_amd64.deb": "a0b8d3acf4a0483048637637d269be93af48d5c16f6f139f53edd13384ad4686",
+    "libsystemd0_247.3-7+deb11u1_amd64.deb": "0bce44fd32e9fa18b68cb89f4010939b9984b9782db2d1985b041fc96e9a02b8",
+    "libtasn1-6_4.16.0-2_amd64.deb": "fd7a200100298c2556e67bdc1a5faf5cf21c3136fa47f381d7e9769233ee88a1",
+    "libthai-data_0.1.28-3_all.deb": "64750cb822e54627a25b5a00cde06e233b5dea28571690215f672af97937f01b",
+    "libthai-dev_0.1.28-3_amd64.deb": "b633b5fbe6220f69fe78019817a3176124e64c5e402cf1142bac14ec93bfbb4b",
+    "libthai0_0.1.28-3_amd64.deb": "446e2b6e8e8a0f5f6c0de0a40c2aa4e1c2cf806efc450c37f5358c7ff1092d6a",
+    "libtiff5_4.2.0-1+deb11u1_amd64.deb": "b22d25e14421a36c4c3b721c04c6312d79ccd91c9a0e2291f58e36b8d4a07fbb",
+    "libtinfo6_6.2+20201114-2_amd64.deb": "aeaf942c71ecc0ed081efdead1a1de304dcd513a9fc06791f26992e76986597b",
+    "libudev1_247.3-7+deb11u1_amd64.deb": "6c654be062de0fd8696808cbc0bfd5ff81e7163c14f0136a132090eda2363831",
+    "libunistring2_0.9.10-4_amd64.deb": "654433ad02d3a8b05c1683c6c29a224500bf343039c34dcec4e5e9515345e3d4",
+    "libuuid1_2.36.1-8+deb11u1_amd64.deb": "31250af4dd3b7d1519326a9a6764d1466a93d8f498cf6545058761ebc38b2823",
+    "libvulkan1_1.2.162.0-1_amd64.deb": "8b3a6e5db7d8bdc369a0d276bfae1551ffc0fa31dbd193d56655c8f553868361",
+    "libwayland-bin_1.18.0-2~exp1.1_amd64.deb": "774e97053d524549044b332469d13eec70c989b4bc00a592019512c17a92978e",
+    "libwayland-client0_1.18.0-2~exp1.1_amd64.deb": "4baf16bb3a35823251453368ee078b6be6a14f97b05c19783b5acd4232a608ea",
+    "libwayland-cursor0_1.18.0-2~exp1.1_amd64.deb": "1b48d1d8e17a95b28a2876c7f2a95667ee1618a5f586d4dff05aeb09488172cb",
+    "libwayland-dev_1.18.0-2~exp1.1_amd64.deb": "3265bf05c0cea760d0e8f5fb5fc68b0f154911de23503e02232dfa59f6b6490c",
+    "libwayland-egl1_1.18.0-2~exp1.1_amd64.deb": "b98e636f08eca9e818e326fc8cd75810dbb50b1ed4e3586c2394e11248e29275",
+    "libwayland-server0_1.18.0-2~exp1.1_amd64.deb": "1df9a6e304bdaebdd53e1044c6eadcda95c914119e9426c2866eaa619a49c85b",
+    "libwebp6_0.6.1-2.1_amd64.deb": "52bfd0f8d3a1bbd2c25fcd72fab857d0f24aea35874af68e057dde869ae3902c",
+    "libx11-6_1.7.2-1_amd64.deb": "086bd667fc07369472a923da015d182bb0c15a72228a5c0e6ddbcbeaab70acd2",
+    "libx11-data_1.7.2-1_all.deb": "049b7eabced516acfdf44a5e81c26d108b16e4987e5d7604ea53eaade74027fb",
+    "libx11-dev_1.7.2-1_amd64.deb": "11e5f9dcded1a1226b3ee02847b86edce525240367b3989274a891a43dc49f5f",
+    "libx11-xcb1_1.7.2-1_amd64.deb": "1f9f2dbe7744a2bb7f855d819f43167df095fe7d5291546bec12865aed045e0c",
+    "libxau-dev_1.0.9-1_amd64.deb": "d1a7f5d484e0879b3b2e8d512894744505e53d078712ce65903fef2ecfd824bb",
+    "libxau6_1.0.9-1_amd64.deb": "679db1c4579ec7c61079adeaae8528adeb2e4bf5465baa6c56233b995d714750",
+    "libxcb-dri2-0_1.14-3_amd64.deb": "fbfc7d55fa00ab7068d015c185363370215c857ac9484d7020c2d9c38c8401b2",
+    "libxcb-dri3-0_1.14-3_amd64.deb": "4dd503b321253f210fe546aae8fe5061fc7d30015cf5580d7843432a71ebc772",
+    "libxcb-glx0_1.14-3_amd64.deb": "61ae35a71148038aad04b021b3adfa0dee4fc06d98e045ec9edfd9e850324876",
+    "libxcb-present0_1.14-3_amd64.deb": "7937af87426de2ed382ba0d6204fee58f4028b332625e2727ebb7ca9a1b32028",
+    "libxcb-render0-dev_1.14-3_amd64.deb": "f3335e206e938c760df5f933e35f370e850050e5c2c9ce0568f190970a6cac41",
+    "libxcb-render0_1.14-3_amd64.deb": "3d653df34e5cd35a78a9aff1d90c18ec0200e5574e27bc779315b855bea2ecc0",
+    "libxcb-shm0-dev_1.14-3_amd64.deb": "283d20ecde030b6905e7042f427a434a6334556a6475b11422278919f4c0c840",
+    "libxcb-shm0_1.14-3_amd64.deb": "0751b48b1c637b5b0cb080159c29b8dd83af8ec771a21c8cc26d180aaab0d351",
+    "libxcb-sync1_1.14-3_amd64.deb": "53e7f18c8a95b2be2024537a753b6bd914af5f4c7aeed175f61155a5a3c8fe88",
+    "libxcb-xfixes0_1.14-3_amd64.deb": "939b29a4eaad5972ba379c2b5f29cf51d7d947b10e68cc2fe96238efcd3d63c2",
+    "libxcb1-dev_1.14-3_amd64.deb": "b75544f334c8963b8b7b0e8a88f8a7cde95a714dddbcda076d4beb669a961b58",
+    "libxcb1_1.14-3_amd64.deb": "d5e0f047ed766f45eb7473947b70f9e8fddbe45ef22ecfd92ab712c0671a93ac",
+    "libxcomposite-dev_0.4.5-1_amd64.deb": "6aecea058e55f46341be898d6e21b933fee5a314e3133ec33b4b88441e7d52b4",
+    "libxcomposite1_0.4.5-1_amd64.deb": "4c26ebf519d2ebc22fc1416dee45e12c4c4ef68aa9b2ed890356830df42b652a",
+    "libxcursor-dev_1.2.0-2_amd64.deb": "c84c43ad4d596bd673288f6035ced4755468b873149181936a0c1fc99cff78aa",
+    "libxcursor1_1.2.0-2_amd64.deb": "d9fee761e4c50572c3ce3c3965b70fcfecd277d0d7d598e102134d12757a3d11",
+    "libxdamage-dev_1.1.5-2_amd64.deb": "34a580b62466411b34a0be2bb0d00c3ec268da96e80d0adc40b379c97e9bac37",
+    "libxdamage1_1.1.5-2_amd64.deb": "1acf6d6117929a7df346d355caeb579798d75feb7e3b3aae58a2d1af735b444f",
+    "libxdmcp-dev_1.1.2-3_amd64.deb": "c6733e5f6463afd261998e408be6eb37f24ce0a64b63bed50a87ddb18ebc1699",
+    "libxdmcp6_1.1.2-3_amd64.deb": "ecb8536f5fb34543b55bb9dc5f5b14c9dbb4150a7bddb3f2287b7cab6e9d25ef",
+    "libxext-dev_1.3.3-1.1_amd64.deb": "0aa17565287ca8a37914e043789ee33c6e1f987acf346dac7175165009c5db7c",
+    "libxext6_1.3.3-1.1_amd64.deb": "dc1ff8a2b60c7dd3c8917ffb9aa65ee6cda52648d9150608683c47319d1c0c8c",
+    "libxfixes-dev_5.0.3-2_amd64.deb": "bacfcd67ca931839a2e2ae87922ecb40e2870470afa86d0c7245288825da2340",
+    "libxfixes3_5.0.3-2_amd64.deb": "58622d0d65c3535bd724c4da62ae7acb71e0e8f527bcbd65daf8c97e6f0ef843",
+    "libxft-dev_2.3.2-2_amd64.deb": "c6919b13423d4e3b41b7650b2d9bb6f6deac2906b3d51d047b5898a169ebc13c",
+    "libxft2_2.3.2-2_amd64.deb": "cd71384b4d511cba69bcee29af326943c7ca12450765f44c40d246608c779aad",
+    "libxi-dev_1.7.10-1_amd64.deb": "736309ff476f0e1594f855cf44e2fb20bf1e594518ce2259eb9b2dd93917f2db",
+    "libxi6_1.7.10-1_amd64.deb": "4d583f43b5396ca5434100a7274613e9983357d80875a47b29a4f3218fe0bec0",
+    "libxinerama-dev_1.1.4-2_amd64.deb": "18047f52c3a1294d61bc2642d22d05bd879c15393c4e8b4ac2ee6a5061585b9b",
+    "libxinerama1_1.1.4-2_amd64.deb": "f692c854935571ee44fe313541d8a9f678a4f11dc513bc43b9d0a501c6dff0bd",
+    "libxkbcommon-dev_1.0.3-2_amd64.deb": "1202d8c64e670876b58f5b3d3f797b1848ec462f7caeef8b1e597ecea18570b6",
+    "libxkbcommon0_1.0.3-2_amd64.deb": "d74d0b9f0a6641b44c279644c7ac627fa7a9b92350b7c6ff37da94352885bcfc",
+    "libxml2_2.9.10+dfsg-6.7+deb11u2_amd64.deb": "4e0fe50fee6c42eeb8a77c55f08baca4f7ebc7d443760ffaaf5f437274f25800",
+    "libxrandr-dev_1.5.1-1_amd64.deb": "67d40174015e8b4e3e2fe3b6fb2990943321a168e0fbb2d12082f637914a0a2e",
+    "libxrandr2_1.5.1-1_amd64.deb": "8fdd8ba4a8ad819731d6bbd903b52851a2ec2f9ef4139d880e9be421ea61338c",
+    "libxrender-dev_0.9.10-1_amd64.deb": "135ed7c8a589e17d21718a91b5a7da48159f33c85e0b337aae9b9f484d3a4954",
+    "libxrender1_0.9.10-1_amd64.deb": "3ea17d07b5aa89012130e2acd92f0fc0ea67314e2f5eab6e33930ef688f48294",
+    "libxshmfence1_1.3-1_amd64.deb": "1a38142e40e3d32dc4f9a326bf5617363b7d9b4bb762fdcdd262f2192092024d",
+    "libxtst-dev_1.2.3-1_amd64.deb": "9ed56e0fd5807afe20cfee8fad16c657c6d7410d7934d8726584794bd77ea989",
+    "libxtst6_1.2.3-1_amd64.deb": "7072f9be17abdb9c5af7d052b19c84d1a6c1c13c30c120a98d284ba73d2da73f",
+    "libxxf86vm1_1.1.4-1+b2_amd64.deb": "6f4ca916aaec26d7000fa7f58de3f71119309ab7590ce1f517abfe1825a676c7",
+    "libz3-4_4.8.10-1_amd64.deb": "7a38c2dd985eb9315857588ee06ff297e2b16de159dec85bd2777a43ebe9f458",
+    "libzstd1_1.4.8+dfsg-2.1_amd64.deb": "5dcadfbb743bfa1c1c773bff91c018f835e8e8c821d423d3836f3ab84773507b",
+    "pango1.0-tools_1.46.2-3_amd64.deb": "e622e68a9451c9d15fd2a5c4c4a95884f33bdfece63f9c0d6cd3953c5d202e74",
+    "perl_5.32.1-4+deb11u2_amd64.deb": "1cebc4516ed7c240b812c7bdd7e6ea0810f513152717ca17ce139ee0dfbc7b0d",
+    "pkg-config_0.29.2-1_amd64.deb": "09a05a23c5fd5baacd488255a6b0114909210691b830fb951acd276e9bcd632a",
+    "sensible-utils_0.0.14_all.deb": "b9a447dc4ec8714196b037e20a2209e62cd669f5450222952f259bda4416b71f",
+    "shared-mime-info_2.0-1_amd64.deb": "de0a814e186af5a941e1fcd3044da62eb155638fcf9616d6005bcfc6696bbe67",
+    "ttf-bitstream-vera_1.10-8.1_all.deb": "ba622edf73744b2951bbd20bfc113a1a875a9b0c6fed1ac9e9c7f4b54dd8a048",
+    "ucf_3.0043_all.deb": "ebef6bcd777b5c0cc2699926f2159db08433aed07c50cb321fd828b28c5e8d53",
+    "uuid-dev_2.36.1-8+deb11u1_amd64.deb": "90a533bbb3b82f5c9bedc5da28965ca8223913099f8ac67213e4f8828bfdd2a1",
+    "wayland-protocols_1.20-1_all.deb": "09bcb6b1d7735a0190ec85f73680dfd53cfa91ff139c8b3d4e18377f94bb6599",
+    "x11-common_7.7+22_all.deb": "5d1c3287826f60c3a82158b803b9c0489b8aad845ca23a53a982eba3dbb82aa3",
+    "x11proto-core-dev_2020.1-1_all.deb": "92941b1b2a7889a67e952a9301339202b6b390b77af939a26ee15c94ef4fad7e",
+    "x11proto-dev_2020.1-1_all.deb": "d5568d587d9ad2664c34c14b0ac538ccb3c567e126ee5291085a8de704a565f5",
+    "x11proto-input-dev_2020.1-1_all.deb": "f2b5dbb98ddafb56b3a6d4d5545812b98e272c146f79adb41e49533eeaa97d3f",
+    "x11proto-randr-dev_2020.1-1_all.deb": "ca63b15ebe65d1e45868d72eb87cd447be3adeb5cc25787db09f65ef05e30c66",
+    "x11proto-record-dev_2020.1-1_all.deb": "dc0ceb54206b03107d31d0ce8665d47ce7c7debac5c3e072e041cdc37f176d3f",
+    "x11proto-render-dev_2020.1-1_all.deb": "f622a9bdd90d51305cd92ee3c5d30ca82f1a20aea3632514965afed6e85589c7",
+    "x11proto-xext-dev_2020.1-1_all.deb": "61e858b8758b8ff63dfc8206d3b00bfbe3ad36ef133dea41b3c5b73dc427d41b",
+    "x11proto-xinerama-dev_2020.1-1_all.deb": "0183efc631edb1308b2bc38ae08f3dc27db735f8d1e84d87bde6416fa023c70d",
+    "xkb-data_2.29-2_all.deb": "9122cccc67e6b3c3aef2fa9c50ef9d793a12f951c76698a02b1f4ceb9e3634e5",
+    "xorg-sgml-doctools_1.11-1.1_all.deb": "168345058319094e475a87ace66f5fb6ae802109650ea8434d672117982b5d0a",
+    "xtrans-dev_1.4.0-1_all.deb": "9ce1af9464faee0c679348dd11cdf63934c12e734a64e0903692b0cb5af38e06",
+    "zlib1g_1.2.11.dfsg-2+deb11u2_amd64.deb": "03d2ab2174af76df6f517b854b77460fbdafc3dac0dca979317da67538159a3e",
+}
diff --git a/debian/packages.bzl b/debian/packages.bzl
index 0f2784a..dfb4ed8 100644
--- a/debian/packages.bzl
+++ b/debian/packages.bzl
@@ -24,7 +24,7 @@
 # 6. Add a new "new_http_archive" entry to the WORKSPACE file for the tarball
 #    you just uploaded.
 
-def download_packages(name, packages, excludes = [], force_includes = [], target_compatible_with = None):
+def download_packages(name, packages, excludes = [], force_includes = [], force_excludes = [], target_compatible_with = None):
     """Downloads a set of packages as well as their dependencies.
 
     You can also specify excludes in case some of the dependencies are meta
@@ -34,10 +34,17 @@
     list to use in a .bzl file. Once you have the packages on
     https://www.frc971.org/Build-Dependencies/ you can add them to a to
     combine_packages rule.
+
+    force_includes lets you include packages that are excluded by default. The
+    dependencies of these force-included packages are also force-included. To
+    counter-act that, you can use "force_excludes". The force-excluded packages
+    are excluded even if they're pulled in as a dependency from a
+    "force_includes" package.
     """
     package_list = " ".join(packages)
     excludes_list = " ".join(["--exclude=%s" % e for e in excludes])
     force_includes = " ".join(["--force-include=%s" % i for i in force_includes])
+    force_excludes = " ".join(["--force-exclude=%s" % e for e in force_excludes])
     native.genrule(
         name = name + "_gen",
         outs = ["%s.sh" % name],
@@ -58,8 +65,8 @@
 # --- end runfiles.bash initialization v2 ---
 
 
-exec "$$(rlocation org_frc971/debian/download_packages)" %s %s %s "$$@"
-END""" % (force_includes, excludes_list, package_list),
+exec "$$(rlocation org_frc971/debian/download_packages)" %s %s %s %s "$$@"
+END""" % (force_includes, force_excludes, excludes_list, package_list),
         target_compatible_with = target_compatible_with,
     )
     native.sh_binary(
diff --git a/frc971/control_loops/python/path_edit.py b/frc971/control_loops/python/path_edit.py
index 7c742c8..437b242 100755
--- a/frc971/control_loops/python/path_edit.py
+++ b/frc971/control_loops/python/path_edit.py
@@ -48,7 +48,10 @@
         # init editing / viewing modes and pointer location
         self.mode = Mode.kPlacing
         self.mousex = 0
+        self.lastx = 0
         self.mousey = 0
+        self.lasty = 0
+        self.drag_start = None
         self.module_path = os.path.dirname(os.path.realpath(sys.argv[0]))
         self.path_to_export = os.path.join(self.module_path,
                                            'points_for_pathedit.json')
@@ -406,6 +409,8 @@
             self.queue_draw()
 
     def do_button_release_event(self, event):
+        self.drag_start = None
+
         self.attempt_append_multisplines()
         self.mousex, self.mousey = self.input_transform.transform_point(
             event.x, event.y)
@@ -430,6 +435,9 @@
         self.mousex, self.mousey = self.input_transform.transform_point(
             event.x, event.y)
 
+        self.lastx = event.x
+        self.lasty = event.y
+
         if self.mode == Mode.kPlacing:
             if self.active_multispline.addPoint(self.mousex, self.mousey):
                 self.mode = Mode.kEditing
@@ -462,6 +470,9 @@
                                 index_multisplines, index_splines,
                                 index_points)
 
+                if self.control_point_index == None:
+                    self.drag_start = (event.x, event.y)
+
             multispline, result = Multispline.nearest_distance(
                 self.multisplines, cur_p)
             if result and result.fun < 0.1:
@@ -484,6 +495,14 @@
 
             multispline.update_lib_spline()
             self.graph.schedule_recalculate(self.multisplines)
+
+        if self.mode == Mode.kEditing and self.drag_start != None and self.control_point_index == None:
+
+            self.zoom_transform.translate(event.x - self.lastx,
+                                          event.y - self.lasty)
+            self.lastx = event.x
+            self.lasty = event.y
+
         self.queue_draw()
 
     def do_scroll_event(self, event):
@@ -505,9 +524,9 @@
         scale = (self.field.width + scale_by) / self.field.width
 
         # This restricts the amount it can be scaled.
-        if self.zoom_transform.xx <= 0.5:
+        if self.zoom_transform.xx <= 0.05:
             scale = max(scale, 1)
-        elif self.zoom_transform.xx >= 16:
+        elif self.zoom_transform.xx >= 32:
             scale = min(scale, 1)
 
         # undo the scaled translation that the old zoom transform did
diff --git a/frc971/vision/BUILD b/frc971/vision/BUILD
index b2b815a..f5bb94d 100644
--- a/frc971/vision/BUILD
+++ b/frc971/vision/BUILD
@@ -79,6 +79,7 @@
         "//aos/events/logging:log_reader",
         "//frc971/analysis:in_process_plotter",
         "//frc971/control_loops/drivetrain:improved_down_estimator",
+        "//frc971/vision:visualize_robot",
         "//frc971/wpilib:imu_batch_fbs",
         "//frc971/wpilib:imu_fbs",
         "//third_party:opencv",
@@ -88,6 +89,46 @@
     ],
 )
 
+flatbuffer_cc_library(
+    name = "target_map_fbs",
+    srcs = ["target_map.fbs"],
+    gen_reflections = 1,
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+)
+
+cc_library(
+    name = "target_mapper",
+    srcs = ["target_mapper.cc"],
+    hdrs = ["target_mapper.h"],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        ":geometry_lib",
+        ":target_map_fbs",
+        "//aos/events:simulated_event_loop",
+        "//frc971/control_loops:control_loop",
+        "//frc971/vision/ceres:pose_graph_2d_lib",
+        "//third_party:opencv",
+        "@com_google_ceres_solver//:ceres",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_test(
+    name = "target_mapper_test",
+    srcs = [
+        "target_mapper_test.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    deps = [
+        ":target_mapper",
+        "//aos/events:simulated_event_loop",
+        "//aos/testing:googletest",
+        "//aos/testing:random_seed",
+    ],
+)
+
 cc_library(
     name = "geometry_lib",
     hdrs = [
@@ -112,3 +153,36 @@
         "//aos/testing:googletest",
     ],
 )
+
+cc_library(
+    name = "visualize_robot",
+    srcs = [
+        "visualize_robot.cc",
+    ],
+    hdrs = [
+        "visualize_robot.h",
+    ],
+    deps = [
+        "//aos:init",
+        "//third_party:opencv",
+        "@com_google_absl//absl/strings:str_format",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
+
+cc_binary(
+    name = "visualize_robot_sample",
+    srcs = [
+        "visualize_robot_sample.cc",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//aos:init",
+        "//frc971/vision:visualize_robot",
+        "//third_party:opencv",
+        "@com_github_google_glog//:glog",
+        "@com_google_ceres_solver//:ceres",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
diff --git a/frc971/vision/calibration_accumulator.cc b/frc971/vision/calibration_accumulator.cc
index ac1946c..f1ab4fc 100644
--- a/frc971/vision/calibration_accumulator.cc
+++ b/frc971/vision/calibration_accumulator.cc
@@ -1,20 +1,22 @@
 #include "frc971/vision/calibration_accumulator.h"
 
-#include <opencv2/aruco/charuco.hpp>
-#include <opencv2/calib3d.hpp>
-#include <opencv2/features2d.hpp>
+#include <algorithm>
+#include <limits>
 #include <opencv2/highgui/highgui.hpp>
-#include <opencv2/imgproc.hpp>
 
 #include "Eigen/Dense"
 #include "aos/events/simulated_event_loop.h"
+#include "aos/network/team_number.h"
 #include "aos/time/time.h"
 #include "frc971/control_loops/quaternion_utils.h"
-#include "frc971/wpilib/imu_batch_generated.h"
 #include "frc971/vision/charuco_lib.h"
+#include "frc971/wpilib/imu_batch_generated.h"
 
 DEFINE_bool(display_undistorted, false,
             "If true, display the undistorted image.");
+DEFINE_string(save_path, "", "Where to store annotated images");
+DEFINE_bool(save_valid_only, false,
+            "If true, only save images with valid pose estimates");
 
 namespace frc971 {
 namespace vision {
@@ -27,59 +29,83 @@
 void CalibrationData::AddCameraPose(
     distributed_clock::time_point distributed_now, Eigen::Vector3d rvec,
     Eigen::Vector3d tvec) {
-  // Always start with IMU reading...
-  if (!imu_points_.empty() && imu_points_[0].first < distributed_now) {
+  // Always start with IMU (or turret) reading...
+  // Note, we may not have a turret, so need to handle that case
+  // If we later get a turret point, then we handle removal of camera points in
+  // AddTurret
+  if ((!imu_points_.empty() && imu_points_[0].first < distributed_now) &&
+      (turret_points_.empty() || turret_points_[0].first < distributed_now)) {
     rot_trans_points_.emplace_back(distributed_now, std::make_pair(rvec, tvec));
   }
 }
 
 void CalibrationData::AddImu(distributed_clock::time_point distributed_now,
                              Eigen::Vector3d gyro, Eigen::Vector3d accel) {
-  imu_points_.emplace_back(distributed_now, std::make_pair(gyro, accel));
+  double zero_threshold = 1e-12;
+  // We seem to be getting 0 readings on IMU, so ignore for now
+  // TODO<Jim>: I think this has been resolved in HandleIMU, but want to leave
+  // this here just in case there are other ways this could happen
+  if ((fabs(accel(0)) < zero_threshold) && (fabs(accel(1)) < zero_threshold) &&
+      (fabs(accel(2)) < zero_threshold)) {
+    LOG(FATAL) << "Ignoring zero value from IMU accelerometer: " << accel
+               << " (gyro is " << gyro << ")";
+  } else {
+    imu_points_.emplace_back(distributed_now, std::make_pair(gyro, accel));
+  }
 }
 
 void CalibrationData::AddTurret(
     aos::distributed_clock::time_point distributed_now, Eigen::Vector2d state) {
-  // We want the turret to be known too when solving.  But, we don't know if we
-  // are going to have a turret until we get the first reading.  In that case,
-  // blow away any camera readings from before.
-  while (!rot_trans_points_.empty() &&
-         rot_trans_points_[0].first < distributed_now) {
-    rot_trans_points_.erase(rot_trans_points_.begin());
+  // We want the turret to be known too when solving.  But, we don't know if
+  // we are going to have a turret until we get the first reading.  In that
+  // case, blow away any camera readings from before.
+  // NOTE: Since the IMU motion is independent of the turret position, we don't
+  // need to remove the IMU readings before the turret
+  if (turret_points_.empty()) {
+    while (!rot_trans_points_.empty() &&
+           rot_trans_points_[0].first < distributed_now) {
+      LOG(INFO) << "Erasing, distributed " << distributed_now;
+      rot_trans_points_.erase(rot_trans_points_.begin());
+    }
   }
   turret_points_.emplace_back(distributed_now, state);
 }
 
 void CalibrationData::ReviewData(CalibrationDataObserver *observer) const {
-  size_t next_imu_point = 0;
   size_t next_camera_point = 0;
-  while (true) {
-    if (next_imu_point != imu_points_.size()) {
-      // There aren't that many combinations, so just brute force them all
-      // rather than being too clever.
-      if (next_camera_point != rot_trans_points_.size()) {
-        if (imu_points_[next_imu_point].first >
-            rot_trans_points_[next_camera_point].first) {
-          // Camera!
-          observer->UpdateCamera(rot_trans_points_[next_camera_point].first,
-                                 rot_trans_points_[next_camera_point].second);
-          ++next_camera_point;
-        } else {
-          // IMU!
-          observer->UpdateIMU(imu_points_[next_imu_point].first,
-                              imu_points_[next_imu_point].second);
-          ++next_imu_point;
-        }
+  size_t next_imu_point = 0;
+  size_t next_turret_point = 0;
+
+  // Just go until one of the data streams runs out.  We lose a few points, but
+  // it makes the logic much easier
+  while (
+      next_camera_point != rot_trans_points_.size() &&
+      next_imu_point != imu_points_.size() &&
+      (turret_points_.empty() || next_turret_point != turret_points_.size())) {
+    // If camera_point is next, update it
+    if ((rot_trans_points_[next_camera_point].first <=
+         imu_points_[next_imu_point].first) &&
+        (turret_points_.empty() ||
+         (rot_trans_points_[next_camera_point].first <=
+          turret_points_[next_turret_point].first))) {
+      // Camera!
+      observer->UpdateCamera(rot_trans_points_[next_camera_point].first,
+                             rot_trans_points_[next_camera_point].second);
+      ++next_camera_point;
+    } else {
+      // If it's not the camera, check if IMU is next
+      if (turret_points_.empty() || (imu_points_[next_imu_point].first <=
+                                     turret_points_[next_turret_point].first)) {
+        // IMU!
+        observer->UpdateIMU(imu_points_[next_imu_point].first,
+                            imu_points_[next_imu_point].second);
+        ++next_imu_point;
       } else {
-        if (next_camera_point != rot_trans_points_.size()) {
-          // Camera!
-          observer->UpdateCamera(rot_trans_points_[next_camera_point].first,
-                                 rot_trans_points_[next_camera_point].second);
-          ++next_camera_point;
-        } else {
-          // Nothing left for either list of points, so we are done.
-          break;
-        }
+        // If it's not IMU or camera, and turret_points is not empty, it must be
+        // the turret!
+        observer->UpdateTurret(turret_points_[next_turret_point].first,
+                               turret_points_[next_turret_point].second);
+        ++next_turret_point;
       }
     }
   }
@@ -98,17 +124,42 @@
       charuco_extractor_(
           image_event_loop_, pi,
           [this](cv::Mat rgb_image, monotonic_clock::time_point eof,
-                 std::vector<int> charuco_ids,
-                 std::vector<cv::Point2f> charuco_corners, bool valid,
-                 Eigen::Vector3d rvec_eigen, Eigen::Vector3d tvec_eigen) {
+                 std::vector<cv::Vec4i> charuco_ids,
+                 std::vector<std::vector<cv::Point2f>> charuco_corners,
+                 bool valid, std::vector<Eigen::Vector3d> rvecs_eigen,
+                 std::vector<Eigen::Vector3d> tvecs_eigen) {
             HandleCharuco(rgb_image, eof, charuco_ids, charuco_corners, valid,
-                          rvec_eigen, tvec_eigen);
+                          rvecs_eigen, tvecs_eigen);
+          }),
+      image_callback_(
+          image_event_loop_,
+          absl::StrCat("/pi",
+                       std::to_string(aos::network::ParsePiNumber(pi).value()),
+                       "/camera"),
+          [this](cv::Mat rgb_image, const monotonic_clock::time_point eof) {
+            charuco_extractor_.HandleImage(rgb_image, eof);
           }),
       data_(data) {
   imu_factory_->OnShutdown([]() { cv::destroyAllWindows(); });
 
+  // Check for IMUValuesBatch topic on both /localizer and /drivetrain channels,
+  // since both are valid/possible
+  std::string imu_channel;
+  if (imu_event_loop->HasChannel<frc971::IMUValuesBatch>("/localizer")) {
+    imu_channel = "/localizer";
+  } else if (imu_event_loop->HasChannel<frc971::IMUValuesBatch>(
+                 "/drivetrain")) {
+    imu_channel = "/drivetrain";
+  } else {
+    LOG(FATAL) << "Couldn't find channel with IMU data for either localizer or "
+                  "drivtrain";
+  }
+
+  VLOG(2) << "Listening for " << frc971::IMUValuesBatch::GetFullyQualifiedName()
+          << " on channel: " << imu_channel;
+
   imu_event_loop_->MakeWatcher(
-      "/drivetrain", [this](const frc971::IMUValuesBatch &imu) {
+      imu_channel, [this](const frc971::IMUValuesBatch &imu) {
         if (!imu.has_readings()) {
           return;
         }
@@ -118,28 +169,17 @@
       });
 }
 
-void Calibration::HandleCharuco(cv::Mat rgb_image,
-                                const monotonic_clock::time_point eof,
-                                std::vector<int> /*charuco_ids*/,
-                                std::vector<cv::Point2f> /*charuco_corners*/,
-                                bool valid, Eigen::Vector3d rvec_eigen,
-                                Eigen::Vector3d tvec_eigen) {
+void Calibration::HandleCharuco(
+    cv::Mat rgb_image, const monotonic_clock::time_point eof,
+    std::vector<cv::Vec4i> /*charuco_ids*/,
+    std::vector<std::vector<cv::Point2f>> /*charuco_corners*/, bool valid,
+    std::vector<Eigen::Vector3d> rvecs_eigen,
+    std::vector<Eigen::Vector3d> tvecs_eigen) {
   if (valid) {
-    data_->AddCameraPose(image_factory_->ToDistributedClock(eof), rvec_eigen,
-                         tvec_eigen);
-
-    // Z -> up
-    // Y -> away from cameras 2 and 3
-    // X -> left
-    Eigen::Vector3d imu(last_value_.accelerometer_x,
-                        last_value_.accelerometer_y,
-                        last_value_.accelerometer_z);
-
-    Eigen::Quaternion<double> imu_to_camera(
-        Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()));
-
-    Eigen::Quaternion<double> board_to_world(
-        Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()));
+    CHECK(rvecs_eigen.size() > 0) << "Require at least one target detected";
+    // We only use one (the first) target detected for calibration
+    data_->AddCameraPose(image_factory_->ToDistributedClock(eof),
+                         rvecs_eigen[0], tvecs_eigen[0]);
 
     Eigen::IOFormat HeavyFmt(Eigen::FullPrecision, 0, ", ", ",\n", "[", "]",
                              "[", "]");
@@ -148,26 +188,47 @@
         std::chrono::duration_cast<std::chrono::duration<double>>(
             image_event_loop_->monotonic_now() - eof)
             .count();
-    LOG(INFO) << std::fixed << std::setprecision(6) << "Age: " << age_double
-              << ", Pose is R:" << rvec_eigen.transpose().format(HeavyFmt)
-              << " T:" << tvec_eigen.transpose().format(HeavyFmt);
+    VLOG(1) << std::fixed << std::setprecision(6) << "Age: " << age_double
+            << ", Pose is R:" << rvecs_eigen[0].transpose().format(HeavyFmt)
+            << "\nT:" << tvecs_eigen[0].transpose().format(HeavyFmt);
   }
 
-  cv::imshow("Display", rgb_image);
+  if (FLAGS_visualize) {
+    if (FLAGS_display_undistorted) {
+      const cv::Size image_size(rgb_image.cols, rgb_image.rows);
+      cv::Mat undistorted_rgb_image(image_size, CV_8UC3);
+      cv::undistort(rgb_image, undistorted_rgb_image,
+                    charuco_extractor_.camera_matrix(),
+                    charuco_extractor_.dist_coeffs());
 
-  if (FLAGS_display_undistorted) {
-    const cv::Size image_size(rgb_image.cols, rgb_image.rows);
-    cv::Mat undistorted_rgb_image(image_size, CV_8UC3);
-    cv::undistort(rgb_image, undistorted_rgb_image,
-                  charuco_extractor_.camera_matrix(),
-                  charuco_extractor_.dist_coeffs());
+      cv::imshow("Display undist", undistorted_rgb_image);
+    }
 
-    cv::imshow("Display undist", undistorted_rgb_image);
+    cv::imshow("Display", rgb_image);
+    cv::waitKey(1);
+  }
+
+  if (FLAGS_save_path != "") {
+    if (!FLAGS_save_valid_only || valid) {
+      static int img_count = 0;
+      std::string image_name = absl::StrFormat("/img_%06d.png", img_count);
+      std::string path = FLAGS_save_path + image_name;
+      VLOG(2) << "Saving image to " << path;
+      cv::imwrite(path, rgb_image);
+      img_count++;
+    }
   }
 }
 
 void Calibration::HandleIMU(const frc971::IMUValues *imu) {
-  VLOG(1) << "IMU " << imu;
+  // Need to check for valid values, since we sometimes don't get them
+  if (!imu->has_gyro_x() || !imu->has_gyro_y() || !imu->has_gyro_z() ||
+      !imu->has_accelerometer_x() || !imu->has_accelerometer_y() ||
+      !imu->has_accelerometer_z()) {
+    return;
+  }
+
+  VLOG(2) << "IMU " << imu;
   imu->UnPackTo(&last_value_);
   Eigen::Vector3d gyro(last_value_.gyro_x, last_value_.gyro_y,
                        last_value_.gyro_z);
diff --git a/frc971/vision/calibration_accumulator.h b/frc971/vision/calibration_accumulator.h
index e4f9c8a..d21f4c6 100644
--- a/frc971/vision/calibration_accumulator.h
+++ b/frc971/vision/calibration_accumulator.h
@@ -56,7 +56,9 @@
 
   size_t camera_samples_size() const { return rot_trans_points_.size(); }
 
-  size_t turret_samples() const { return turret_points_.size(); }
+  size_t imu_samples_size() const { return imu_points_.size(); }
+
+  size_t turret_samples_size() const { return turret_points_.size(); }
 
  private:
   std::vector<std::pair<aos::distributed_clock::time_point,
@@ -82,14 +84,18 @@
               aos::EventLoop *image_event_loop, aos::EventLoop *imu_event_loop,
               std::string_view pi, CalibrationData *data);
 
-  // Processes a charuco detection.
+  // Processes a charuco detection that is returned from charuco_lib.
+  // For a valid detection(s), it stores camera observation
+  // Also optionally displays and saves annotated images based on visualize and
+  // save_path flags, respectively
   void HandleCharuco(cv::Mat rgb_image,
                      const aos::monotonic_clock::time_point eof,
-                     std::vector<int> /*charuco_ids*/,
-                     std::vector<cv::Point2f> /*charuco_corners*/, bool valid,
-                     Eigen::Vector3d rvec_eigen, Eigen::Vector3d tvec_eigen);
+                     std::vector<cv::Vec4i> /*charuco_ids*/,
+                     std::vector<std::vector<cv::Point2f>> /*charuco_corners*/,
+                     bool valid, std::vector<Eigen::Vector3d> rvecs_eigen,
+                     std::vector<Eigen::Vector3d> tvecs_eigen);
 
-  // Processes an IMU reading.
+  // Processes an IMU reading by storing for later processing
   void HandleIMU(const frc971::IMUValues *imu);
 
  private:
@@ -99,6 +105,7 @@
   aos::NodeEventLoopFactory *imu_factory_;
 
   CharucoExtractor charuco_extractor_;
+  ImageCallback image_callback_;
 
   CalibrationData *data_;
 
diff --git a/frc971/vision/ceres/BUILD b/frc971/vision/ceres/BUILD
new file mode 100644
index 0000000..5622b55
--- /dev/null
+++ b/frc971/vision/ceres/BUILD
@@ -0,0 +1,24 @@
+# Copy of ceres example code from their repo
+cc_library(
+    name = "pose_graph_2d_lib",
+    hdrs = [
+        "angle_local_parameterization.h",
+        "normalize_angle.h",
+        "pose_graph_2d_error_term.h",
+        "read_g2o.h",
+        "types.h",
+    ],
+    copts = [
+        # Needed to silence GFlags complaints.
+        "-Wno-sign-compare",
+        "-Wno-unused-parameter",
+        "-Wno-format-nonliteral",
+    ],
+    target_compatible_with = ["@platforms//os:linux"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "@com_github_gflags_gflags//:gflags",
+        "@com_google_ceres_solver//:ceres",
+        "@org_tuxfamily_eigen//:eigen",
+    ],
+)
diff --git a/frc971/vision/ceres/angle_local_parameterization.h b/frc971/vision/ceres/angle_local_parameterization.h
new file mode 100644
index 0000000..a81637c
--- /dev/null
+++ b/frc971/vision/ceres/angle_local_parameterization.h
@@ -0,0 +1,64 @@
+// Ceres Solver - A fast non-linear least squares minimizer
+// Copyright 2016 Google Inc. All rights reserved.
+// http://ceres-solver.org/
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice,
+//   this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above copyright notice,
+//   this list of conditions and the following disclaimer in the documentation
+//   and/or other materials provided with the distribution.
+// * Neither the name of Google Inc. nor the names of its contributors may be
+//   used to endorse or promote products derived from this software without
+//   specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+//
+// Author: vitus@google.com (Michael Vitus)
+
+#ifndef CERES_EXAMPLES_POSE_GRAPH_2D_ANGLE_LOCAL_PARAMETERIZATION_H_
+#define CERES_EXAMPLES_POSE_GRAPH_2D_ANGLE_LOCAL_PARAMETERIZATION_H_
+
+#include "ceres/local_parameterization.h"
+#include "normalize_angle.h"
+
+namespace ceres {
+namespace examples {
+
+// Defines a local parameterization for updating the angle to be constrained in
+// [-pi to pi).
+class AngleLocalParameterization {
+ public:
+  template <typename T>
+  bool operator()(const T* theta_radians,
+                  const T* delta_theta_radians,
+                  T* theta_radians_plus_delta) const {
+    *theta_radians_plus_delta =
+        NormalizeAngle(*theta_radians + *delta_theta_radians);
+
+    return true;
+  }
+
+  static ceres::LocalParameterization* Create() {
+    return (new ceres::AutoDiffLocalParameterization<AngleLocalParameterization,
+                                                     1,
+                                                     1>);
+  }
+};
+
+}  // namespace examples
+}  // namespace ceres
+
+#endif  // CERES_EXAMPLES_POSE_GRAPH_2D_ANGLE_LOCAL_PARAMETERIZATION_H_
diff --git a/frc971/vision/ceres/normalize_angle.h b/frc971/vision/ceres/normalize_angle.h
new file mode 100644
index 0000000..c215671
--- /dev/null
+++ b/frc971/vision/ceres/normalize_angle.h
@@ -0,0 +1,53 @@
+// Ceres Solver - A fast non-linear least squares minimizer
+// Copyright 2016 Google Inc. All rights reserved.
+// http://ceres-solver.org/
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice,
+//   this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above copyright notice,
+//   this list of conditions and the following disclaimer in the documentation
+//   and/or other materials provided with the distribution.
+// * Neither the name of Google Inc. nor the names of its contributors may be
+//   used to endorse or promote products derived from this software without
+//   specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+//
+// Author: vitus@google.com (Michael Vitus)
+
+#ifndef CERES_EXAMPLES_POSE_GRAPH_2D_NORMALIZE_ANGLE_H_
+#define CERES_EXAMPLES_POSE_GRAPH_2D_NORMALIZE_ANGLE_H_
+
+#include <cmath>
+
+#include "ceres/ceres.h"
+
+namespace ceres {
+namespace examples {
+
+// Normalizes the angle in radians between [-pi and pi).
+template <typename T>
+inline T NormalizeAngle(const T& angle_radians) {
+  // Use ceres::floor because it is specialized for double and Jet types.
+  T two_pi(2.0 * M_PI);
+  return angle_radians -
+         two_pi * ceres::floor((angle_radians + T(M_PI)) / two_pi);
+}
+
+}  // namespace examples
+}  // namespace ceres
+
+#endif  // CERES_EXAMPLES_POSE_GRAPH_2D_NORMALIZE_ANGLE_H_
diff --git a/frc971/vision/ceres/pose_graph_2d_error_term.h b/frc971/vision/ceres/pose_graph_2d_error_term.h
new file mode 100644
index 0000000..2df31f6
--- /dev/null
+++ b/frc971/vision/ceres/pose_graph_2d_error_term.h
@@ -0,0 +1,119 @@
+// Ceres Solver - A fast non-linear least squares minimizer
+// Copyright 2016 Google Inc. All rights reserved.
+// http://ceres-solver.org/
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice,
+//   this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above copyright notice,
+//   this list of conditions and the following disclaimer in the documentation
+//   and/or other materials provided with the distribution.
+// * Neither the name of Google Inc. nor the names of its contributors may be
+//   used to endorse or promote products derived from this software without
+//   specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+//
+// Author: vitus@google.com (Michael Vitus)
+//
+// Cost function for a 2D pose graph formulation.
+
+#ifndef CERES_EXAMPLES_POSE_GRAPH_2D_POSE_GRAPH_2D_ERROR_TERM_H_
+#define CERES_EXAMPLES_POSE_GRAPH_2D_POSE_GRAPH_2D_ERROR_TERM_H_
+
+#include "Eigen/Core"
+
+namespace ceres {
+namespace examples {
+
+template <typename T>
+Eigen::Matrix<T, 2, 2> RotationMatrix2D(T yaw_radians) {
+  const T cos_yaw = ceres::cos(yaw_radians);
+  const T sin_yaw = ceres::sin(yaw_radians);
+
+  Eigen::Matrix<T, 2, 2> rotation;
+  rotation << cos_yaw, -sin_yaw, sin_yaw, cos_yaw;
+  return rotation;
+}
+
+// Computes the error term for two poses that have a relative pose measurement
+// between them. Let the hat variables be the measurement.
+//
+// residual =  information^{1/2} * [  r_a^T * (p_b - p_a) - \hat{p_ab}   ]
+//                                 [ Normalize(yaw_b - yaw_a - \hat{yaw_ab}) ]
+//
+// where r_a is the rotation matrix that rotates a vector represented in frame A
+// into the global frame, and Normalize(*) ensures the angles are in the range
+// [-pi, pi).
+class PoseGraph2dErrorTerm {
+ public:
+  PoseGraph2dErrorTerm(double x_ab,
+                       double y_ab,
+                       double yaw_ab_radians,
+                       const Eigen::Matrix3d& sqrt_information)
+      : p_ab_(x_ab, y_ab),
+        yaw_ab_radians_(yaw_ab_radians),
+        sqrt_information_(sqrt_information) {}
+
+  template <typename T>
+  bool operator()(const T* const x_a,
+                  const T* const y_a,
+                  const T* const yaw_a,
+                  const T* const x_b,
+                  const T* const y_b,
+                  const T* const yaw_b,
+                  T* residuals_ptr) const {
+    const Eigen::Matrix<T, 2, 1> p_a(*x_a, *y_a);
+    const Eigen::Matrix<T, 2, 1> p_b(*x_b, *y_b);
+
+    Eigen::Map<Eigen::Matrix<T, 3, 1>> residuals_map(residuals_ptr);
+
+    residuals_map.template head<2>() =
+        RotationMatrix2D(*yaw_a).transpose() * (p_b - p_a) - p_ab_.cast<T>();
+    residuals_map(2) = ceres::examples::NormalizeAngle(
+        (*yaw_b - *yaw_a) - static_cast<T>(yaw_ab_radians_));
+
+    // Scale the residuals by the square root information matrix to account for
+    // the measurement uncertainty.
+    residuals_map = sqrt_information_.template cast<T>() * residuals_map;
+
+    return true;
+  }
+
+  static ceres::CostFunction* Create(double x_ab,
+                                     double y_ab,
+                                     double yaw_ab_radians,
+                                     const Eigen::Matrix3d& sqrt_information) {
+    return (new ceres::
+                AutoDiffCostFunction<PoseGraph2dErrorTerm, 3, 1, 1, 1, 1, 1, 1>(
+                    new PoseGraph2dErrorTerm(
+                        x_ab, y_ab, yaw_ab_radians, sqrt_information)));
+  }
+
+  EIGEN_MAKE_ALIGNED_OPERATOR_NEW
+
+ private:
+  // The position of B relative to A in the A frame.
+  const Eigen::Vector2d p_ab_;
+  // The orientation of frame B relative to frame A.
+  const double yaw_ab_radians_;
+  // The inverse square root of the measurement covariance matrix.
+  const Eigen::Matrix3d sqrt_information_;
+};
+
+}  // namespace examples
+}  // namespace ceres
+
+#endif  // CERES_EXAMPLES_POSE_GRAPH_2D_POSE_GRAPH_2D_ERROR_TERM_H_
diff --git a/frc971/vision/ceres/read_g2o.h b/frc971/vision/ceres/read_g2o.h
new file mode 100644
index 0000000..fea32e9
--- /dev/null
+++ b/frc971/vision/ceres/read_g2o.h
@@ -0,0 +1,143 @@
+// Ceres Solver - A fast non-linear least squares minimizer
+// Copyright 2016 Google Inc. All rights reserved.
+// http://ceres-solver.org/
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice,
+//   this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above copyright notice,
+//   this list of conditions and the following disclaimer in the documentation
+//   and/or other materials provided with the distribution.
+// * Neither the name of Google Inc. nor the names of its contributors may be
+//   used to endorse or promote products derived from this software without
+//   specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+//
+// Author: vitus@google.com (Michael Vitus)
+//
+// Reads a file in the g2o filename format that describes a pose graph problem.
+
+#ifndef EXAMPLES_CERES_READ_G2O_H_
+#define EXAMPLES_CERES_READ_G2O_H_
+
+#include <fstream>
+#include <string>
+
+#include "glog/logging.h"
+
+namespace ceres {
+namespace examples {
+
+// Reads a single pose from the input and inserts it into the map. Returns false
+// if there is a duplicate entry.
+template <typename Pose, typename Allocator>
+bool ReadVertex(std::ifstream* infile,
+                std::map<int, Pose, std::less<int>, Allocator>* poses) {
+  int id;
+  Pose pose;
+  *infile >> id >> pose;
+
+  // Ensure we don't have duplicate poses.
+  if (poses->find(id) != poses->end()) {
+    LOG(ERROR) << "Duplicate vertex with ID: " << id;
+    return false;
+  }
+  (*poses)[id] = pose;
+
+  return true;
+}
+
+// Reads the contraints between two vertices in the pose graph
+template <typename Constraint, typename Allocator>
+void ReadConstraint(std::ifstream* infile,
+                    std::vector<Constraint, Allocator>* constraints) {
+  Constraint constraint;
+  *infile >> constraint;
+
+  constraints->push_back(constraint);
+}
+
+// Reads a file in the g2o filename format that describes a pose graph
+// problem. The g2o format consists of two entries, vertices and constraints.
+//
+// In 2D, a vertex is defined as follows:
+//
+// VERTEX_SE2 ID x_meters y_meters yaw_radians
+//
+// A constraint is defined as follows:
+//
+// EDGE_SE2 ID_A ID_B A_x_B A_y_B A_yaw_B I_11 I_12 I_13 I_22 I_23 I_33
+//
+// where I_ij is the (i, j)-th entry of the information matrix for the
+// measurement.
+//
+//
+// In 3D, a vertex is defined as follows:
+//
+// VERTEX_SE3:QUAT ID x y z q_x q_y q_z q_w
+//
+// where the quaternion is in Hamilton form.
+// A constraint is defined as follows:
+//
+// EDGE_SE3:QUAT ID_a ID_b x_ab y_ab z_ab q_x_ab q_y_ab q_z_ab q_w_ab I_11 I_12 I_13 ... I_16 I_22 I_23 ... I_26 ... I_66 // NOLINT
+//
+// where I_ij is the (i, j)-th entry of the information matrix for the
+// measurement. Only the upper-triangular part is stored. The measurement order
+// is the delta position followed by the delta orientation.
+template <typename Pose,
+          typename Constraint,
+          typename MapAllocator,
+          typename VectorAllocator>
+bool ReadG2oFile(const std::string& filename,
+                 std::map<int, Pose, std::less<int>, MapAllocator>* poses,
+                 std::vector<Constraint, VectorAllocator>* constraints) {
+  CHECK(poses != NULL);
+  CHECK(constraints != NULL);
+
+  poses->clear();
+  constraints->clear();
+
+  std::ifstream infile(filename.c_str());
+  if (!infile) {
+    return false;
+  }
+
+  std::string data_type;
+  while (infile.good()) {
+    // Read whether the type is a node or a constraint.
+    infile >> data_type;
+    if (data_type == Pose::name()) {
+      if (!ReadVertex(&infile, poses)) {
+        return false;
+      }
+    } else if (data_type == Constraint::name()) {
+      ReadConstraint(&infile, constraints);
+    } else {
+      LOG(ERROR) << "Unknown data type: " << data_type;
+      return false;
+    }
+
+    // Clear any trailing whitespace from the line.
+    infile >> std::ws;
+  }
+
+  return true;
+}
+
+}  // namespace examples
+}  // namespace ceres
+
+#endif  // EXAMPLES_CERES_READ_G2O_H_
diff --git a/frc971/vision/ceres/types.h b/frc971/vision/ceres/types.h
new file mode 100644
index 0000000..3c13824
--- /dev/null
+++ b/frc971/vision/ceres/types.h
@@ -0,0 +1,101 @@
+// Ceres Solver - A fast non-linear least squares minimizer
+// Copyright 2016 Google Inc. All rights reserved.
+// http://ceres-solver.org/
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// * Redistributions of source code must retain the above copyright notice,
+//   this list of conditions and the following disclaimer.
+// * Redistributions in binary form must reproduce the above copyright notice,
+//   this list of conditions and the following disclaimer in the documentation
+//   and/or other materials provided with the distribution.
+// * Neither the name of Google Inc. nor the names of its contributors may be
+//   used to endorse or promote products derived from this software without
+//   specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+//
+// Author: vitus@google.com (Michael Vitus)
+//
+// Defines the types used in the 2D pose graph SLAM formulation. Each vertex of
+// the graph has a unique integer ID with a position and orientation. There are
+// delta transformation constraints between two vertices.
+
+#ifndef CERES_EXAMPLES_POSE_GRAPH_2D_TYPES_H_
+#define CERES_EXAMPLES_POSE_GRAPH_2D_TYPES_H_
+
+#include <fstream>
+
+#include "Eigen/Core"
+#include "normalize_angle.h"
+
+namespace ceres {
+namespace examples {
+
+// The state for each vertex in the pose graph.
+struct Pose2d {
+  double x;
+  double y;
+  double yaw_radians;
+
+  // The name of the data type in the g2o file format.
+  static std::string name() { return "VERTEX_SE2"; }
+};
+
+inline std::istream& operator>>(std::istream& input, Pose2d& pose) {
+  input >> pose.x >> pose.y >> pose.yaw_radians;
+  // Normalize the angle between -pi to pi.
+  pose.yaw_radians = NormalizeAngle(pose.yaw_radians);
+  return input;
+}
+
+// The constraint between two vertices in the pose graph. The constraint is the
+// transformation from vertex id_begin to vertex id_end.
+struct Constraint2d {
+  int id_begin;
+  int id_end;
+
+  double x;
+  double y;
+  double yaw_radians;
+
+  // The inverse of the covariance matrix for the measurement. The order of the
+  // entries are x, y, and yaw.
+  Eigen::Matrix3d information;
+
+  // The name of the data type in the g2o file format.
+  static std::string name() { return "EDGE_SE2"; }
+};
+
+inline std::istream& operator>>(std::istream& input, Constraint2d& constraint) {
+  input >> constraint.id_begin >> constraint.id_end >> constraint.x >>
+      constraint.y >> constraint.yaw_radians >> constraint.information(0, 0) >>
+      constraint.information(0, 1) >> constraint.information(0, 2) >>
+      constraint.information(1, 1) >> constraint.information(1, 2) >>
+      constraint.information(2, 2);
+
+  // Set the lower triangular part of the information matrix.
+  constraint.information(1, 0) = constraint.information(0, 1);
+  constraint.information(2, 0) = constraint.information(0, 2);
+  constraint.information(2, 1) = constraint.information(1, 2);
+
+  // Normalize the angle between -pi to pi.
+  constraint.yaw_radians = NormalizeAngle(constraint.yaw_radians);
+  return input;
+}
+
+}  // namespace examples
+}  // namespace ceres
+
+#endif  // CERES_EXAMPLES_POSE_GRAPH_2D_TYPES_H_
diff --git a/frc971/vision/charuco_lib.cc b/frc971/vision/charuco_lib.cc
index fde5394..0db571f 100644
--- a/frc971/vision/charuco_lib.cc
+++ b/frc971/vision/charuco_lib.cc
@@ -17,13 +17,17 @@
 #include "y2020/vision/sift/sift_training_generated.h"
 #include "y2020/vision/tools/python_code/sift_training_data.h"
 
-DEFINE_uint32(min_targets, 10,
-              "The mininum number of targets required to match.");
-DEFINE_bool(large_board, true, "If true, use the large calibration board.");
-DEFINE_bool(coarse_pattern, true, "If true, use coarse arucos; else, use fine");
 DEFINE_string(board_template_path, "",
               "If specified, write an image to the specified path for the "
               "charuco board pattern.");
+DEFINE_bool(coarse_pattern, true, "If true, use coarse arucos; else, use fine");
+DEFINE_bool(large_board, true, "If true, use the large calibration board.");
+DEFINE_uint32(
+    min_charucos, 10,
+    "The mininum number of aruco targets in charuco board required to match.");
+DEFINE_string(target_type, "charuco",
+              "Type of target: april_tag|aruco|charuco|charuco_diamond");
+DEFINE_bool(visualize, false, "Whether to visualize the resulting data.");
 
 namespace frc971 {
 namespace vision {
@@ -87,7 +91,8 @@
 
 ImageCallback::ImageCallback(
     aos::EventLoop *event_loop, std::string_view channel,
-    std::function<void(cv::Mat, monotonic_clock::time_point)> &&fn)
+    std::function<void(cv::Mat, monotonic_clock::time_point)> &&handle_image_fn)
+
     : event_loop_(event_loop),
       server_fetcher_(
           event_loop_->MakeFetcher<aos::message_bridge::ServerStatistics>(
@@ -97,7 +102,7 @@
           event_loop_->GetChannel<CameraImage>(channel)
               ->source_node()
               ->string_view())),
-      handle_image_(std::move(fn)) {
+      handle_image_(std::move(handle_image_fn)) {
   event_loop_->MakeWatcher(channel, [this](const CameraImage &image) {
     const monotonic_clock::time_point eof_source_node =
         monotonic_clock::time_point(
@@ -150,47 +155,142 @@
   });
 }
 
+void CharucoExtractor::SetupTargetData() {
+  // TODO(Jim): Put correct values here
+  marker_length_ = 0.15;
+  square_length_ = 0.1651;
+
+  // Only charuco board has a board associated with it
+  board_ = static_cast<cv::Ptr<cv::aruco::CharucoBoard>>(NULL);
+
+  if (FLAGS_target_type == "charuco" || FLAGS_target_type == "aruco") {
+    dictionary_ = cv::aruco::getPredefinedDictionary(
+        FLAGS_large_board ? cv::aruco::DICT_5X5_250 : cv::aruco::DICT_6X6_250);
+
+    if (FLAGS_target_type == "charuco") {
+      LOG(INFO) << "Using " << (FLAGS_large_board ? " large " : " small ")
+                << " charuco board with "
+                << (FLAGS_coarse_pattern ? "coarse" : "fine") << " pattern";
+      board_ =
+          (FLAGS_large_board
+               ? (FLAGS_coarse_pattern ? cv::aruco::CharucoBoard::create(
+                                             12, 9, 0.06, 0.04666, dictionary_)
+                                       : cv::aruco::CharucoBoard::create(
+                                             25, 18, 0.03, 0.0233, dictionary_))
+               : (FLAGS_coarse_pattern ? cv::aruco::CharucoBoard::create(
+                                             7, 5, 0.04, 0.025, dictionary_)
+                                       // TODO(jim): Need to figure out what
+                                       // size is for small board, fine pattern
+                                       : cv::aruco::CharucoBoard::create(
+                                             7, 5, 0.03, 0.0233, dictionary_)));
+      if (!FLAGS_board_template_path.empty()) {
+        cv::Mat board_image;
+        board_->draw(cv::Size(600, 500), board_image, 10, 1);
+        cv::imwrite(FLAGS_board_template_path, board_image);
+      }
+    }
+  } else if (FLAGS_target_type == "charuco_diamond") {
+    // TODO<Jim>: Measure this
+    marker_length_ = 0.15;
+    square_length_ = 0.1651;
+    dictionary_ = cv::aruco::getPredefinedDictionary(cv::aruco::DICT_4X4_250);
+  } else if (FLAGS_target_type == "april_tag") {
+    // Current printout is supposed to be 200mm
+    // TODO<Jim>: Verify this
+    square_length_ = 0.2;
+    dictionary_ =
+        cv::aruco::getPredefinedDictionary(cv::aruco::DICT_APRILTAG_36h11);
+  } else {
+    // Bail out if it's not a supported target
+    LOG(FATAL) << "Target type undefined: " << FLAGS_target_type
+               << " vs. april_tag|aruco|charuco|charuco_diamond";
+  }
+}
+
+void CharucoExtractor::DrawTargetPoses(cv::Mat rgb_image,
+                                       std::vector<cv::Vec3d> rvecs,
+                                       std::vector<cv::Vec3d> tvecs) {
+  const Eigen::Matrix<double, 3, 4> camera_projection =
+      Eigen::Matrix<double, 3, 4>::Identity();
+
+  int x_coord = 10;
+  int y_coord = 0;
+  // draw axis for each marker
+  for (uint i = 0; i < rvecs.size(); i++) {
+    Eigen::Vector3d rvec_eigen, tvec_eigen;
+    cv::cv2eigen(rvecs[i], rvec_eigen);
+    cv::cv2eigen(tvecs[i], tvec_eigen);
+
+    Eigen::Quaternion<double> rotation(
+        frc971::controls::ToQuaternionFromRotationVector(rvec_eigen));
+    Eigen::Translation3d translation(tvec_eigen);
+
+    const Eigen::Affine3d board_to_camera = translation * rotation;
+
+    Eigen::Vector3d result = eigen_camera_matrix_ * camera_projection *
+                             board_to_camera * Eigen::Vector3d::Zero();
+
+    // Found that drawAxis hangs if you try to draw with z values too
+    // small (trying to draw axes at inifinity)
+    // TODO<Jim>: Explore what real thresholds for this should be;
+    // likely Don't need to get rid of negative values
+    if (result.z() < 0.01) {
+      LOG(INFO) << "Skipping, due to z value too small: " << result.z();
+    } else {
+      result /= result.z();
+      if (FLAGS_target_type == "charuco") {
+        cv::aruco::drawAxis(rgb_image, camera_matrix_, dist_coeffs_, rvecs[i],
+                            tvecs[i], 0.1);
+      } else {
+        cv::drawFrameAxes(rgb_image, camera_matrix_, dist_coeffs_, rvecs[i],
+                          tvecs[i], 0.1);
+      }
+    }
+    std::stringstream ss;
+    ss << "tvec[" << i << "] = " << tvecs[i];
+    y_coord += 25;
+    cv::putText(rgb_image, ss.str(), cv::Point(x_coord, y_coord),
+                cv::FONT_HERSHEY_PLAIN, 1.0, cv::Scalar(255, 255, 255));
+    ss.str("");
+    ss << "rvec[" << i << "] = " << rvecs[i];
+    y_coord += 25;
+    cv::putText(rgb_image, ss.str(), cv::Point(x_coord, y_coord),
+                cv::FONT_HERSHEY_PLAIN, 1.0, cv::Scalar(255, 255, 255));
+  }
+}
+
+void CharucoExtractor::PackPoseResults(
+    std::vector<cv::Vec3d> &rvecs, std::vector<cv::Vec3d> &tvecs,
+    std::vector<Eigen::Vector3d> *rvecs_eigen,
+    std::vector<Eigen::Vector3d> *tvecs_eigen) {
+  for (cv::Vec3d rvec : rvecs) {
+    Eigen::Vector3d rvec_eigen = Eigen::Vector3d::Zero();
+    cv::cv2eigen(rvec, rvec_eigen);
+    rvecs_eigen->emplace_back(rvec_eigen);
+  }
+
+  for (cv::Vec3d tvec : tvecs) {
+    Eigen::Vector3d tvec_eigen = Eigen::Vector3d::Zero();
+    cv::cv2eigen(tvec, tvec_eigen);
+    tvecs_eigen->emplace_back(tvec_eigen);
+  }
+}
+
 CharucoExtractor::CharucoExtractor(
     aos::EventLoop *event_loop, std::string_view pi,
-    std::function<void(cv::Mat, monotonic_clock::time_point, std::vector<int>,
-                       std::vector<cv::Point2f>, bool, Eigen::Vector3d,
-                       Eigen::Vector3d)> &&fn)
+    std::function<void(cv::Mat, monotonic_clock::time_point,
+                       std::vector<cv::Vec4i>,
+                       std::vector<std::vector<cv::Point2f>>, bool,
+                       std::vector<Eigen::Vector3d>,
+                       std::vector<Eigen::Vector3d>)> &&handle_charuco_fn)
     : event_loop_(event_loop),
       calibration_(SiftTrainingData(), pi),
-      dictionary_(cv::aruco::getPredefinedDictionary(
-          FLAGS_large_board ? cv::aruco::DICT_5X5_250
-                            : cv::aruco::DICT_6X6_250)),
-      board_(
-          FLAGS_large_board
-              ? (FLAGS_coarse_pattern ? cv::aruco::CharucoBoard::create(
-                                            12, 9, 0.06, 0.04666, dictionary_)
-                                      : cv::aruco::CharucoBoard::create(
-                                            25, 18, 0.03, 0.0233, dictionary_))
-              : (FLAGS_coarse_pattern ? cv::aruco::CharucoBoard::create(
-                                            7, 5, 0.04, 0.025, dictionary_)
-                                      // TODO(jim): Need to figure out what size
-                                      // is for small board, fine pattern
-                                      : cv::aruco::CharucoBoard::create(
-                                            7, 5, 0.03, 0.0233, dictionary_))),
       camera_matrix_(calibration_.CameraIntrinsics()),
       eigen_camera_matrix_(calibration_.CameraIntrinsicsEigen()),
       dist_coeffs_(calibration_.CameraDistCoeffs()),
       pi_number_(aos::network::ParsePiNumber(pi)),
-      image_callback_(
-          event_loop,
-          absl::StrCat("/pi", std::to_string(pi_number_.value()), "/camera"),
-          [this](cv::Mat rgb_image, const monotonic_clock::time_point eof) {
-            HandleImage(rgb_image, eof);
-          }),
-      handle_charuco_(std::move(fn)) {
-  LOG(INFO) << "Using " << (FLAGS_large_board ? "large" : "small")
-            << " board with " << (FLAGS_coarse_pattern ? "coarse" : "fine")
-            << " pattern";
-  if (!FLAGS_board_template_path.empty()) {
-    cv::Mat board_image;
-    board_->draw(cv::Size(600, 500), board_image, 10, 1);
-    cv::imwrite(FLAGS_board_template_path, board_image);
-  }
+      handle_charuco_(std::move(handle_charuco_fn)) {
+  SetupTargetData();
 
   LOG(INFO) << "Camera matrix " << camera_matrix_;
   LOG(INFO) << "Distortion Coefficients " << dist_coeffs_;
@@ -207,91 +307,143 @@
       std::chrono::duration_cast<std::chrono::duration<double>>(
           event_loop_->monotonic_now() - eof)
           .count();
+
+  // Set up the variables we'll use in the callback function
+  bool valid = false;
+  // Return a list of poses; for Charuco Board there will be just one
+  std::vector<Eigen::Vector3d> rvecs_eigen;
+  std::vector<Eigen::Vector3d> tvecs_eigen;
+
+  // ids and corners for initial aruco marker detections
   std::vector<int> marker_ids;
   std::vector<std::vector<cv::Point2f>> marker_corners;
 
-  cv::aruco::detectMarkers(rgb_image, board_->dictionary, marker_corners,
-                           marker_ids);
+  // ids and corners for final, refined board / marker detections
+  // Using Vec4i type since it supports Charuco Diamonds
+  // And overloading it using 1st int in Vec4i for others target types
+  std::vector<cv::Vec4i> result_ids;
+  std::vector<std::vector<cv::Point2f>> result_corners;
 
-  std::vector<cv::Point2f> charuco_corners;
-  std::vector<int> charuco_ids;
-  bool valid = false;
-  Eigen::Vector3d rvec_eigen = Eigen::Vector3d::Zero();
-  Eigen::Vector3d tvec_eigen = Eigen::Vector3d::Zero();
+  // Do initial marker detection; this is the same for all target types
+  cv::aruco::detectMarkers(rgb_image, dictionary_, marker_corners, marker_ids);
+  cv::aruco::drawDetectedMarkers(rgb_image, marker_corners, marker_ids);
 
-  // If at least one marker detected
-  if (marker_ids.size() >= FLAGS_min_targets) {
-    // Run everything twice, once with the calibration, and once
-    // without. This lets us both calibrate, and also print out the pose
-    // real time with the previous calibration.
-    cv::aruco::interpolateCornersCharuco(marker_corners, marker_ids, rgb_image,
-                                         board_, charuco_corners, charuco_ids);
+  VLOG(2) << "Handle Image, with target type = " << FLAGS_target_type << " and "
+          << marker_ids.size() << " markers detected initially";
 
-    std::vector<cv::Point2f> charuco_corners_with_calibration;
-    std::vector<int> charuco_ids_with_calibration;
+  if (marker_ids.size() == 0) {
+    VLOG(2) << "Didn't find any markers";
+  } else {
+    if (FLAGS_target_type == "charuco") {
+      std::vector<int> charuco_ids;
+      std::vector<cv::Point2f> charuco_corners;
 
-    cv::aruco::interpolateCornersCharuco(
-        marker_corners, marker_ids, rgb_image, board_,
-        charuco_corners_with_calibration, charuco_ids_with_calibration,
-        camera_matrix_, dist_coeffs_);
+      // If enough aruco markers detected for the Charuco board
+      if (marker_ids.size() >= FLAGS_min_charucos) {
+        // Run everything twice, once with the calibration, and once
+        // without. This lets us both collect data to calibrate the
+        // intrinsics of the camera (to determine the intrinsics from
+        // multiple samples), and also to use data from a previous/stored
+        // calibration to determine a more accurate pose in real time (used
+        // for extrinsics calibration)
+        cv::aruco::interpolateCornersCharuco(marker_corners, marker_ids,
+                                             rgb_image, board_, charuco_corners,
+                                             charuco_ids);
 
-    cv::aruco::drawDetectedMarkers(rgb_image, marker_corners, marker_ids);
+        std::vector<cv::Point2f> charuco_corners_with_calibration;
+        std::vector<int> charuco_ids_with_calibration;
 
-    if (charuco_ids.size() >= FLAGS_min_targets) {
-      cv::aruco::drawDetectedCornersCharuco(rgb_image, charuco_corners,
-                                            charuco_ids, cv::Scalar(255, 0, 0));
+        // This call uses a previous intrinsic calibration to get more
+        // accurate marker locations, for a better pose estimate
+        cv::aruco::interpolateCornersCharuco(
+            marker_corners, marker_ids, rgb_image, board_,
+            charuco_corners_with_calibration, charuco_ids_with_calibration,
+            camera_matrix_, dist_coeffs_);
 
-      cv::Vec3d rvec, tvec;
-      valid = cv::aruco::estimatePoseCharucoBoard(
-          charuco_corners_with_calibration, charuco_ids_with_calibration,
-          board_, camera_matrix_, dist_coeffs_, rvec, tvec);
+        if (charuco_ids.size() >= FLAGS_min_charucos) {
+          cv::aruco::drawDetectedCornersCharuco(
+              rgb_image, charuco_corners, charuco_ids, cv::Scalar(255, 0, 0));
 
-      // if charuco pose is valid
-      if (valid) {
-        cv::cv2eigen(rvec, rvec_eigen);
-        cv::cv2eigen(tvec, tvec_eigen);
+          cv::Vec3d rvec, tvec;
+          valid = cv::aruco::estimatePoseCharucoBoard(
+              charuco_corners_with_calibration, charuco_ids_with_calibration,
+              board_, camera_matrix_, dist_coeffs_, rvec, tvec);
 
-        Eigen::Quaternion<double> rotation(
-            frc971::controls::ToQuaternionFromRotationVector(rvec_eigen));
-        Eigen::Translation3d translation(tvec_eigen);
+          // if charuco pose is valid, return pose, with ids and corners
+          if (valid) {
+            std::vector<cv::Vec3d> rvecs, tvecs;
+            rvecs.emplace_back(rvec);
+            tvecs.emplace_back(tvec);
+            DrawTargetPoses(rgb_image, rvecs, tvecs);
 
-        const Eigen::Affine3d board_to_camera = translation * rotation;
-
-        Eigen::Matrix<double, 3, 4> camera_projection =
-            Eigen::Matrix<double, 3, 4>::Identity();
-        Eigen::Vector3d result = eigen_camera_matrix_ * camera_projection *
-                                 board_to_camera * Eigen::Vector3d::Zero();
-
-        // Found that drawAxis hangs if you try to draw with z values too small
-        // (trying to draw axes at inifinity)
-        // TODO<Jim>: Explore what real thresholds for this should be; likely
-        // Don't need to get rid of negative values
-        if (result.z() < 0.01) {
-          LOG(INFO) << "Skipping, due to z value too small: " << result.z();
-          valid = false;
+            PackPoseResults(rvecs, tvecs, &rvecs_eigen, &tvecs_eigen);
+            // Store the corners without calibration, since we use them to
+            // do calibration
+            result_corners.emplace_back(charuco_corners);
+            for (auto id : charuco_ids) {
+              result_ids.emplace_back(cv::Vec4i{id, 0, 0, 0});
+            }
+          } else {
+            VLOG(2) << "Age: " << age_double << ", invalid charuco board pose";
+          }
         } else {
-          result /= result.z();
-          cv::circle(rgb_image, cv::Point(result.x(), result.y()), 4,
-                     cv::Scalar(255, 255, 255), 0, cv::LINE_8);
-
-          cv::aruco::drawAxis(rgb_image, camera_matrix_, dist_coeffs_, rvec,
-                              tvec, 0.1);
+          VLOG(2) << "Age: " << age_double << ", not enough charuco IDs, got "
+                  << charuco_ids.size() << ", needed " << FLAGS_min_charucos;
         }
       } else {
-        LOG(INFO) << "Age: " << age_double << ", invalid pose";
+        VLOG(2) << "Age: " << age_double
+                << ", not enough marker IDs for charuco board, got "
+                << marker_ids.size() << ", needed " << FLAGS_min_charucos;
+      }
+    } else if (FLAGS_target_type == "april_tag" ||
+               FLAGS_target_type == "aruco") {
+      // estimate pose for april tags doesn't return valid, so marking true
+      valid = true;
+      std::vector<cv::Vec3d> rvecs, tvecs;
+      cv::aruco::estimatePoseSingleMarkers(marker_corners, square_length_,
+                                           camera_matrix_, dist_coeffs_, rvecs,
+                                           tvecs);
+      DrawTargetPoses(rgb_image, rvecs, tvecs);
+
+      PackPoseResults(rvecs, tvecs, &rvecs_eigen, &tvecs_eigen);
+      for (uint i = 0; i < marker_ids.size(); i++) {
+        result_ids.emplace_back(cv::Vec4i{marker_ids[i], 0, 0, 0});
+      }
+      result_corners = marker_corners;
+    } else if (FLAGS_target_type == "charuco_diamond") {
+      // Extract the diamonds associated with the markers
+      std::vector<cv::Vec4i> diamond_ids;
+      std::vector<std::vector<cv::Point2f>> diamond_corners;
+      cv::aruco::detectCharucoDiamond(rgb_image, marker_corners, marker_ids,
+                                      square_length_ / marker_length_,
+                                      diamond_corners, diamond_ids);
+
+      // Check to see if we found any diamond targets
+      if (diamond_ids.size() > 0) {
+        cv::aruco::drawDetectedDiamonds(rgb_image, diamond_corners,
+                                        diamond_ids);
+
+        // estimate pose for diamonds doesn't return valid, so marking true
+        valid = true;
+        std::vector<cv::Vec3d> rvecs, tvecs;
+        cv::aruco::estimatePoseSingleMarkers(diamond_corners, square_length_,
+                                             camera_matrix_, dist_coeffs_,
+                                             rvecs, tvecs);
+        DrawTargetPoses(rgb_image, rvecs, tvecs);
+
+        PackPoseResults(rvecs, tvecs, &rvecs_eigen, &tvecs_eigen);
+        result_ids = diamond_ids;
+        result_corners = diamond_corners;
+      } else {
+        VLOG(2) << "Found aruco markers, but no charuco diamond targets";
       }
     } else {
-      VLOG(2) << "Age: " << age_double << ", not enough charuco IDs, got "
-              << charuco_ids.size() << ", needed " << FLAGS_min_targets;
+      LOG(FATAL) << "Unknown target type: " << FLAGS_target_type;
     }
-  } else {
-    VLOG(2) << "Age: " << age_double << ", not enough marker IDs, got "
-            << marker_ids.size() << ", needed " << FLAGS_min_targets;
-    cv::aruco::drawDetectedMarkers(rgb_image, marker_corners, marker_ids);
   }
 
-  handle_charuco_(rgb_image, eof, charuco_ids, charuco_corners, valid,
-                  rvec_eigen, tvec_eigen);
+  handle_charuco_(rgb_image, eof, result_ids, result_corners, valid,
+                  rvecs_eigen, tvecs_eigen);
 }
 
 }  // namespace vision
diff --git a/frc971/vision/charuco_lib.h b/frc971/vision/charuco_lib.h
index a54bfca..362bb7d 100644
--- a/frc971/vision/charuco_lib.h
+++ b/frc971/vision/charuco_lib.h
@@ -15,6 +15,9 @@
 #include "y2020/vision/sift/sift_generated.h"
 #include "y2020/vision/sift/sift_training_generated.h"
 
+DECLARE_bool(visualize);
+DECLARE_string(target_type);
+
 namespace frc971 {
 namespace vision {
 
@@ -41,14 +44,16 @@
   const sift::CameraCalibration *camera_calibration_;
 };
 
-// Class to call a function with a cv::Mat and age when an image shows up on the
-// provided channel.  This hides all the conversions and wrangling needed to
-// view the image.
+// Helper class to call a function with a cv::Mat and age when an image shows up
+// on the provided channel.  This hides all the conversions and wrangling needed
+// to view the image.
+// Can connect this with HandleImage function from CharucoExtrator for
+// full-service callback functionality
 class ImageCallback {
  public:
-  ImageCallback(
-      aos::EventLoop *event_loop, std::string_view channel,
-      std::function<void(cv::Mat, aos::monotonic_clock::time_point)> &&fn);
+  ImageCallback(aos::EventLoop *event_loop, std::string_view channel,
+                std::function<void(cv::Mat, aos::monotonic_clock::time_point)>
+                    &&handle_image_fn);
 
  private:
   aos::EventLoop *event_loop_;
@@ -65,16 +70,25 @@
   //   cv::Mat -> image with overlays drawn on it.
   //   monotonic_clock::time_point -> Time on this node when this image was
   //                                  captured.
-  //   std::vector<int> -> charuco_ids
-  //   std::vector<cv::Point2f> -> charuco_corners
+  //   std::vector<Vec4i> -> target ids (aruco/april in first slot of Vec4i)
+  // NOTE: We use Vec4i since that stores the ids for the charuco diamond target
+  //   std::vector<std::vector<cv::Point2f>> -> charuco_corners
   //   bool -> true if rvec/tvec is valid.
-  //   Eigen::Vector3d -> rvec
-  //   Eigen::Vector3d -> tvec
+  //   std::vector<Eigen::Vector3d> -> rvec
+  //   std::vector<Eigen::Vector3d> -> tvec
+  // NOTE: we return as a vector since all but charuco boards could have
+  // multiple targets in an image; for charuco boards, there should be just one
+  // element
   CharucoExtractor(
       aos::EventLoop *event_loop, std::string_view pi,
       std::function<void(cv::Mat, aos::monotonic_clock::time_point,
-                         std::vector<int>, std::vector<cv::Point2f>, bool,
-                         Eigen::Vector3d, Eigen::Vector3d)> &&fn);
+                         std::vector<cv::Vec4i>,
+                         std::vector<std::vector<cv::Point2f>>, bool,
+                         std::vector<Eigen::Vector3d>,
+                         std::vector<Eigen::Vector3d>)> &&handle_charuco_fn);
+
+  // Handles the image by detecting the charuco board in it.
+  void HandleImage(cv::Mat rgb_image, aos::monotonic_clock::time_point eof);
 
   // Returns the aruco dictionary in use.
   cv::Ptr<cv::aruco::Dictionary> dictionary() const { return dictionary_; }
@@ -87,8 +101,20 @@
   const cv::Mat dist_coeffs() const { return dist_coeffs_; }
 
  private:
-  // Handles the image by detecting the charuco board in it.
-  void HandleImage(cv::Mat rgb_image, aos::monotonic_clock::time_point eof);
+  // Creates the dictionary, board, and other parameters for the appropriate
+  // (ch)aruco target
+  void SetupTargetData();
+
+  // Draw the axes from the pose(s) on the image
+  void DrawTargetPoses(cv::Mat rgb_image, std::vector<cv::Vec3d> rvecs,
+                       std::vector<cv::Vec3d> tvecs);
+
+  // Helper function to convert rotation (rvecs) and translation (tvecs) vectors
+  // into Eigen vectors and store in corresponding vectors
+  void PackPoseResults(std::vector<cv::Vec3d> &rvecs,
+                       std::vector<cv::Vec3d> &tvecs,
+                       std::vector<Eigen::Vector3d> *rvecs_eigen,
+                       std::vector<Eigen::Vector3d> *tvecs_eigen);
 
   aos::EventLoop *event_loop_;
   CameraCalibration calibration_;
@@ -96,18 +122,26 @@
   cv::Ptr<cv::aruco::Dictionary> dictionary_;
   cv::Ptr<cv::aruco::CharucoBoard> board_;
 
+  // Length of a side of the aruco marker
+  double marker_length_;
+  // Length of a side of the checkerboard squares (around the marker)
+  double square_length_;
+
+  // Intrinsic calibration matrix
   const cv::Mat camera_matrix_;
+  // Intrinsic calibration matrix as Eigen::Matrix3d
   const Eigen::Matrix3d eigen_camera_matrix_;
+  // Intrinsic distortion coefficients
   const cv::Mat dist_coeffs_;
 
+  // Index number of the raspberry pi
   const std::optional<uint16_t> pi_number_;
 
-  ImageCallback image_callback_;
-
   // Function to call.
-  std::function<void(cv::Mat, aos::monotonic_clock::time_point,
-                     std::vector<int>, std::vector<cv::Point2f>, bool,
-                     Eigen::Vector3d, Eigen::Vector3d)>
+  std::function<void(
+      cv::Mat, aos::monotonic_clock::time_point, std::vector<cv::Vec4i>,
+      std::vector<std::vector<cv::Point2f>>, bool, std::vector<Eigen::Vector3d>,
+      std::vector<Eigen::Vector3d>)>
       handle_charuco_;
 };
 
diff --git a/frc971/vision/extrinsics_calibration.cc b/frc971/vision/extrinsics_calibration.cc
index a71f14d..cd6d183 100644
--- a/frc971/vision/extrinsics_calibration.cc
+++ b/frc971/vision/extrinsics_calibration.cc
@@ -6,6 +6,13 @@
 #include "frc971/control_loops/runge_kutta.h"
 #include "frc971/vision/calibration_accumulator.h"
 #include "frc971/vision/charuco_lib.h"
+#include "frc971/vision/visualize_robot.h"
+
+#include <opencv2/core.hpp>
+#include <opencv2/core/eigen.hpp>
+#include <opencv2/highgui.hpp>
+#include <opencv2/highgui/highgui.hpp>
+#include <opencv2/imgproc.hpp>
 
 namespace frc971 {
 namespace vision {
@@ -79,17 +86,9 @@
   virtual void ObserveCameraUpdate(
       distributed_clock::time_point /*t*/,
       Eigen::Vector3d /*board_to_camera_rotation*/,
+      Eigen::Vector3d /*board_to_camera_translation*/,
       Eigen::Quaternion<Scalar> /*imu_to_world_rotation*/,
-      Affine3s /*imu_to_world*/) {}
-
-  void UpdateTurret(distributed_clock::time_point t,
-                    Eigen::Vector2d state) override {
-    state_ = state;
-    state_time_ = t;
-  }
-
-  Eigen::Vector2d state_ = Eigen::Vector2d::Zero();
-  distributed_clock::time_point state_time_ = distributed_clock::min_time;
+      Affine3s /*imu_to_world*/, double /*turret_angle*/) {}
 
   // Observes a camera measurement by applying a kalman filter correction and
   // accumulating up the error associated with the step.
@@ -100,8 +99,9 @@
     const double pivot_angle =
         state_time_ == distributed_clock::min_time
             ? 0.0
-            : state_(0) +
-                  state_(1) * chrono::duration<double>(t - state_time_).count();
+            : turret_state_(0) +
+                  turret_state_(1) *
+                      chrono::duration<double>(t - state_time_).count();
 
     const Eigen::Quaternion<Scalar> board_to_camera_rotation(
         frc971::controls::ToQuaternionFromRotationVector(rt.first)
@@ -143,9 +143,12 @@
     H(2, 2) = static_cast<Scalar>(1.0);
     const Eigen::Matrix<Scalar, 3, 1> y = z - H * x_hat_;
 
+    // TODO<Jim>: Need to understand dependence on this-- solutions vary by 20cm
+    // when changing from 0.01 -> 0.1
+    double obs_noise_var = ::std::pow(0.01, 2);
     const Eigen::Matrix<double, 3, 3> R =
-        (::Eigen::DiagonalMatrix<double, 3>().diagonal() << ::std::pow(0.01, 2),
-         ::std::pow(0.01, 2), ::std::pow(0.01, 2))
+        (::Eigen::DiagonalMatrix<double, 3>().diagonal() << obs_noise_var,
+         obs_noise_var, obs_noise_var)
             .finished()
             .asDiagonal();
 
@@ -163,7 +166,8 @@
         Eigen::Matrix<Scalar, 3, 1>(error.x(), error.y(), error.z()));
     position_errors_.emplace_back(y);
 
-    ObserveCameraUpdate(t, rt.first, imu_to_world_rotation, imu_to_world);
+    ObserveCameraUpdate(t, rt.first, rt.second, imu_to_world_rotation,
+                        imu_to_world, pivot_angle);
   }
 
   virtual void ObserveIMUUpdate(
@@ -179,7 +183,22 @@
     ObserveIMUUpdate(t, wa);
   }
 
+  virtual void ObserveTurretUpdate(distributed_clock::time_point /*t*/,
+                                   Eigen::Vector2d /*turret_state*/) {}
+
+  void UpdateTurret(distributed_clock::time_point t,
+                    Eigen::Vector2d state) override {
+    turret_state_ = state;
+    state_time_ = t;
+
+    ObserveTurretUpdate(t, state);
+  }
+
+  Eigen::Vector2d turret_state_ = Eigen::Vector2d::Zero();
+  distributed_clock::time_point state_time_ = distributed_clock::min_time;
+
   const Eigen::Quaternion<Scalar> &orientation() const { return orientation_; }
+  const Eigen::Matrix<Scalar, 6, 1> &get_x_hat() const { return x_hat_; }
 
   size_t num_errors() const { return errors_.size(); }
   Scalar errorx(size_t i) const { return errors_[i].x(); }
@@ -373,73 +392,253 @@
       vz.emplace_back(x_hat(5));
     }
 
+    // TODO<Jim>: Could probably still do a bit more work on naming
+    // conventions and what is being shown here
     frc971::analysis::Plotter plotter;
-    plotter.AddFigure("position");
-    plotter.AddLine(times_, rx, "x_hat(0)");
-    plotter.AddLine(times_, ry, "x_hat(1)");
-    plotter.AddLine(times_, rz, "x_hat(2)");
-    plotter.AddLine(camera_times_, camera_x_, "Camera x");
-    plotter.AddLine(camera_times_, camera_y_, "Camera y");
-    plotter.AddLine(camera_times_, camera_z_, "Camera z");
-    plotter.AddLine(camera_times_, camera_error_x_, "Camera error x");
-    plotter.AddLine(camera_times_, camera_error_y_, "Camera error y");
-    plotter.AddLine(camera_times_, camera_error_z_, "Camera error z");
+    plotter.AddFigure("bot (imu) position");
+    plotter.AddLine(times_, x, "x_hat(0)");
+    plotter.AddLine(times_, y, "x_hat(1)");
+    plotter.AddLine(times_, z, "x_hat(2)");
     plotter.Publish();
 
-    plotter.AddFigure("error");
-    plotter.AddLine(times_, rx, "x_hat(0)");
-    plotter.AddLine(times_, ry, "x_hat(1)");
-    plotter.AddLine(times_, rz, "x_hat(2)");
-    plotter.AddLine(camera_times_, camera_error_x_, "Camera error x");
-    plotter.AddLine(camera_times_, camera_error_y_, "Camera error y");
-    plotter.AddLine(camera_times_, camera_error_z_, "Camera error z");
+    plotter.AddFigure("bot (imu) rotation");
+    plotter.AddLine(camera_times_, imu_rot_x_, "bot (imu) rot x");
+    plotter.AddLine(camera_times_, imu_rot_y_, "bot (imu) rot y");
+    plotter.AddLine(camera_times_, imu_rot_z_, "bot (imu) rot z");
+    plotter.Publish();
+
+    plotter.AddFigure("rotation error");
+    plotter.AddLine(camera_times_, rotation_error_x_, "Error x");
+    plotter.AddLine(camera_times_, rotation_error_y_, "Error y");
+    plotter.AddLine(camera_times_, rotation_error_z_, "Error z");
+    plotter.Publish();
+
+    plotter.AddFigure("translation error");
+    plotter.AddLine(camera_times_, translation_error_x_, "Error x");
+    plotter.AddLine(camera_times_, translation_error_y_, "Error y");
+    plotter.AddLine(camera_times_, translation_error_z_, "Error z");
     plotter.Publish();
 
     plotter.AddFigure("imu");
-    plotter.AddLine(camera_times_, world_gravity_x_, "world_gravity(0)");
-    plotter.AddLine(camera_times_, world_gravity_y_, "world_gravity(1)");
-    plotter.AddLine(camera_times_, world_gravity_z_, "world_gravity(2)");
-    plotter.AddLine(imu_times_, imu_x_, "imu x");
-    plotter.AddLine(imu_times_, imu_y_, "imu y");
-    plotter.AddLine(imu_times_, imu_z_, "imu z");
-    plotter.AddLine(times_, rx, "rotation x");
-    plotter.AddLine(times_, ry, "rotation y");
-    plotter.AddLine(times_, rz, "rotation z");
+    plotter.AddLine(imu_times_, imu_rate_x_, "imu gyro x");
+    plotter.AddLine(imu_times_, imu_rate_y_, "imu gyro y");
+    plotter.AddLine(imu_times_, imu_rate_z_, "imu gyro z");
+    plotter.AddLine(imu_times_, imu_accel_x_, "imu accel x");
+    plotter.AddLine(imu_times_, imu_accel_y_, "imu accel y");
+    plotter.AddLine(imu_times_, imu_accel_z_, "imu accel z");
+    plotter.AddLine(camera_times_, accel_minus_gravity_x_,
+                    "accel_minus_gravity(0)");
+    plotter.AddLine(camera_times_, accel_minus_gravity_y_,
+                    "accel_minus_gravity(1)");
+    plotter.AddLine(camera_times_, accel_minus_gravity_z_,
+                    "accel_minus_gravity(2)");
     plotter.Publish();
 
-    plotter.AddFigure("raw");
-    plotter.AddLine(imu_times_, imu_x_, "imu x");
-    plotter.AddLine(imu_times_, imu_y_, "imu y");
-    plotter.AddLine(imu_times_, imu_z_, "imu z");
-    plotter.AddLine(imu_times_, imu_ratex_, "omega x");
-    plotter.AddLine(imu_times_, imu_ratey_, "omega y");
-    plotter.AddLine(imu_times_, imu_ratez_, "omega z");
-    plotter.AddLine(camera_times_, raw_camera_x_, "Camera x");
-    plotter.AddLine(camera_times_, raw_camera_y_, "Camera y");
-    plotter.AddLine(camera_times_, raw_camera_z_, "Camera z");
+    plotter.AddFigure("raw camera observations");
+    plotter.AddLine(camera_times_, raw_camera_rot_x_, "Camera rot x");
+    plotter.AddLine(camera_times_, raw_camera_rot_y_, "Camera rot y");
+    plotter.AddLine(camera_times_, raw_camera_rot_z_, "Camera rot z");
+    plotter.AddLine(camera_times_, raw_camera_trans_x_, "Camera trans x");
+    plotter.AddLine(camera_times_, raw_camera_trans_y_, "Camera trans y");
+    plotter.AddLine(camera_times_, raw_camera_trans_z_, "Camera trans z");
     plotter.Publish();
 
-    plotter.AddFigure("xyz vel");
-    plotter.AddLine(times_, x, "x");
-    plotter.AddLine(times_, y, "y");
-    plotter.AddLine(times_, z, "z");
+    plotter.AddFigure("xyz pos, vel estimates");
+    plotter.AddLine(times_, x, "x (x_hat(0))");
+    plotter.AddLine(times_, y, "y (x_hat(1))");
+    plotter.AddLine(times_, z, "z (x_hat(2))");
     plotter.AddLine(times_, vx, "vx");
     plotter.AddLine(times_, vy, "vy");
     plotter.AddLine(times_, vz, "vz");
-    plotter.AddLine(camera_times_, camera_position_x_, "Camera x");
-    plotter.AddLine(camera_times_, camera_position_y_, "Camera y");
-    plotter.AddLine(camera_times_, camera_position_z_, "Camera z");
+    plotter.AddLine(camera_times_, imu_position_x_, "x pos from board");
+    plotter.AddLine(camera_times_, imu_position_y_, "y pos from board");
+    plotter.AddLine(camera_times_, imu_position_z_, "z pos from board");
     plotter.Publish();
 
+    // If we've got 'em, plot 'em
+    if (turret_times_.size() > 0) {
+      plotter.AddFigure("Turret angle");
+      plotter.AddLine(turret_times_, turret_angles_, "turret angle");
+      plotter.Publish();
+    }
+
     plotter.Spin();
   }
 
+  void Visualize(const CalibrationParameters &calibration_parameters) {
+    // Set up virtual camera for visualization
+    VisualizeRobot vis_robot;
+
+    // Set virtual viewing point 10 meters above the origin, rotated so the
+    // camera faces straight down
+    Eigen::Translation3d camera_trans(0, 0, 10.0);
+    Eigen::AngleAxisd camera_rot(M_PI, Eigen::Vector3d::UnitX());
+    Eigen::Affine3d camera_viewpoint = camera_trans * camera_rot;
+    vis_robot.SetViewpoint(camera_viewpoint);
+
+    // Create camera with origin in center, and focal length suitable to fit
+    // robot visualization fully in view
+    int image_width = 500;
+    double focal_length = 1000.0;
+    double intr[] = {focal_length, 0.0,          image_width / 2.0,
+                     0.0,          focal_length, image_width / 2.0,
+                     0.0,          0.0,          1.0};
+    cv::Mat camera_mat = cv::Mat(3, 3, CV_64FC1, intr);
+    cv::Mat dist_coeffs = cv::Mat(1, 5, CV_64F, 0.0);
+    vis_robot.SetCameraParameters(camera_mat);
+    vis_robot.SetDistortionCoefficients(dist_coeffs);
+
+    /*
+    // Draw an initial visualization
+    Eigen::Vector3d T_world_imu_vec =
+        calibration_parameters.initial_state.block<3, 1>(0, 0);
+    Eigen::Translation3d T_world_imu(T_world_imu_vec);
+    Eigen::Affine3d H_world_imu =
+        T_world_imu * calibration_parameters.initial_orientation;
+
+    vis_robot.DrawFrameAxes(H_world_imu, "imu");
+
+    Eigen::Quaterniond R_imu_pivot(calibration_parameters.pivot_to_imu);
+    Eigen::Translation3d T_imu_pivot(
+        calibration_parameters.pivot_to_imu_translation);
+    Eigen::Affine3d H_imu_pivot = T_imu_pivot * R_imu_pivot;
+    Eigen::Affine3d H_world_pivot = H_world_imu * H_imu_pivot;
+    vis_robot.DrawFrameAxes(H_world_pivot, "pivot");
+
+    Eigen::Affine3d H_imupivot_camerapivot(
+        Eigen::AngleAxisd(1.5, Eigen::Vector3d::UnitZ()));
+    Eigen::Quaterniond R_camera_pivot(calibration_parameters.pivot_to_camera);
+    Eigen::Translation3d T_camera_pivot(
+        calibration_parameters.pivot_to_camera_translation);
+    Eigen::Affine3d H_camera_pivot = T_camera_pivot * R_camera_pivot;
+    Eigen::Affine3d H_world_camera = H_world_imu * H_imu_pivot *
+                                     H_imupivot_camerapivot *
+                                     H_camera_pivot.inverse();
+    vis_robot.DrawFrameAxes(H_world_camera, "camera");
+
+    cv::imshow("Original poses", image_mat);
+    cv::waitKey();
+    */
+
+    uint current_state_index = 0;
+    uint current_turret_index = 0;
+    for (uint i = 0; i < camera_times_.size() - 1; i++) {
+      // reset image each frame
+      cv::Mat image_mat =
+          cv::Mat::zeros(cv::Size(image_width, image_width), CV_8UC3);
+      vis_robot.SetImage(image_mat);
+
+      // Jump to state closest to current camera_time
+      while (camera_times_[i] > times_[current_state_index] &&
+             current_state_index < times_.size()) {
+        current_state_index++;
+      }
+
+      // H_world_imu: map from world origin to imu (robot) frame
+      Eigen::Vector3d T_world_imu_vec =
+          x_hats_[current_state_index].block<3, 1>(0, 0);
+      Eigen::Translation3d T_world_imu(T_world_imu_vec);
+      Eigen::Affine3d H_world_imu =
+          T_world_imu * orientations_[current_state_index];
+
+      vis_robot.DrawFrameAxes(H_world_imu, "imu_kf");
+
+      // H_world_pivot: map from world origin to pivot point
+      // Do this via the imu (using H_world_pivot = H_world_imu * H_imu_pivot)
+      Eigen::Quaterniond R_imu_pivot(calibration_parameters.pivot_to_imu);
+      Eigen::Translation3d T_imu_pivot(
+          calibration_parameters.pivot_to_imu_translation);
+      Eigen::Affine3d H_imu_pivot = T_imu_pivot * R_imu_pivot;
+      Eigen::Affine3d H_world_pivot = H_world_imu * H_imu_pivot;
+      vis_robot.DrawFrameAxes(H_world_pivot, "pivot");
+
+      // Jump to turret sample closest to current camera_time
+      while (turret_times_.size() > 0 &&
+             camera_times_[i] > turret_times_[current_turret_index] &&
+             current_turret_index < turret_times_.size()) {
+        current_turret_index++;
+      }
+
+      // Draw the camera frame
+
+      Eigen::Affine3d H_imupivot_camerapivot(Eigen::Matrix4d::Identity());
+      if (turret_angles_.size() > 0) {
+        // Need to rotate by the turret angle in the middle of all this
+        H_imupivot_camerapivot = Eigen::Affine3d(Eigen::AngleAxisd(
+            turret_angles_[current_turret_index], Eigen::Vector3d::UnitZ()));
+      }
+
+      // H_world_camera: map from world origin to camera frame
+      // Via imu->pivot->pivot rotation
+      Eigen::Quaterniond R_camera_pivot(calibration_parameters.pivot_to_camera);
+      Eigen::Translation3d T_camera_pivot(
+          calibration_parameters.pivot_to_camera_translation);
+      Eigen::Affine3d H_camera_pivot = T_camera_pivot * R_camera_pivot;
+      Eigen::Affine3d H_world_camera = H_world_imu * H_imu_pivot *
+                                       H_imupivot_camerapivot *
+                                       H_camera_pivot.inverse();
+      vis_robot.DrawFrameAxes(H_world_camera, "camera");
+
+      // H_world_board: board location from world reference frame
+      // Uses the estimate from camera-> board, on top of H_world_camera
+      Eigen::Quaterniond R_camera_board(
+          frc971::controls::ToQuaternionFromRotationVector(
+              board_to_camera_rotations_[i]));
+      Eigen::Translation3d T_camera_board(board_to_camera_translations_[i]);
+      Eigen::Affine3d H_camera_board = T_camera_board * R_camera_board;
+      Eigen::Affine3d H_world_board = H_world_camera * H_camera_board;
+
+      vis_robot.DrawFrameAxes(H_world_board, "board est");
+
+      // H_world_board_solve: board in world frame based on solver
+      // Find world -> board via solved parameter of H_world_board
+      // (parameter "board_to_world" and assuming origin of board frame is
+      // coincident with origin of world frame, i.e., T_world_board == 0)
+      Eigen::Quaterniond R_world_board_solve(
+          calibration_parameters.board_to_world);
+      Eigen::Translation3d T_world_board_solve(Eigen::Vector3d(0, 0, 0));
+      Eigen::Affine3d H_world_board_solve =
+          T_world_board_solve * R_world_board_solve;
+
+      vis_robot.DrawFrameAxes(H_world_board_solve, "board_solve");
+
+      // H_world_imu_from_board: imu location in world frame, via the board
+      // Determine the imu location via the board_to_world solved
+      // transformation
+      Eigen::Affine3d H_world_imu_from_board =
+          H_world_board_solve * H_camera_board.inverse() * H_camera_pivot *
+          H_imupivot_camerapivot.inverse() * H_imu_pivot.inverse();
+
+      vis_robot.DrawFrameAxes(H_world_imu_from_board, "imu_board");
+
+      // These errors should match up with the residuals in the optimizer
+      // (Note: rotation seems to differ by sign, but that's OK in residual)
+      Eigen::Affine3d error = H_world_imu_from_board.inverse() * H_world_imu;
+      Eigen::Vector3d trans_error =
+          H_world_imu_from_board.translation() - H_world_imu.translation();
+      Eigen::Quaterniond error_rot(error.rotation());
+      VLOG(1) << "Error: \n"
+              << "Rotation: " << error_rot.coeffs().transpose() << "\n"
+              << "Translation: " << trans_error.transpose();
+
+      cv::imshow("Live", image_mat);
+      cv::waitKey(50);
+
+      if (i % 200 == 0) {
+        LOG(INFO) << "Pausing at step " << i;
+        cv::waitKey();
+      }
+    }
+    LOG(INFO) << "Finished visualizing robot.  Press any key to continue";
+    cv::waitKey();
+  }
+
   void ObserveIntegrated(distributed_clock::time_point t,
                          Eigen::Matrix<double, 6, 1> x_hat,
                          Eigen::Quaternion<double> orientation,
                          Eigen::Matrix<double, 6, 6> p) override {
-    VLOG(1) << t << " -> " << p;
-    VLOG(1) << t << " xhat -> " << x_hat.transpose();
+    VLOG(2) << t << " -> " << p;
+    VLOG(2) << t << " xhat -> " << x_hat.transpose();
     times_.emplace_back(chrono::duration<double>(t.time_since_epoch()).count());
     x_hats_.emplace_back(x_hat);
     orientations_.emplace_back(orientation);
@@ -448,83 +647,126 @@
   void ObserveIMUUpdate(
       distributed_clock::time_point t,
       std::pair<Eigen::Vector3d, Eigen::Vector3d> wa) override {
-    imu_times_.emplace_back(chrono::duration<double>(t.time_since_epoch()).count());
-    imu_ratex_.emplace_back(wa.first.x());
-    imu_ratey_.emplace_back(wa.first.y());
-    imu_ratez_.emplace_back(wa.first.z());
-    imu_x_.emplace_back(wa.second.x());
-    imu_y_.emplace_back(wa.second.y());
-    imu_z_.emplace_back(wa.second.z());
+    imu_times_.emplace_back(
+        chrono::duration<double>(t.time_since_epoch()).count());
+    imu_rate_x_.emplace_back(wa.first.x());
+    imu_rate_y_.emplace_back(wa.first.y());
+    imu_rate_z_.emplace_back(wa.first.z());
+    imu_accel_x_.emplace_back(wa.second.x());
+    imu_accel_y_.emplace_back(wa.second.y());
+    imu_accel_z_.emplace_back(wa.second.z());
 
     last_accel_ = wa.second;
   }
 
   void ObserveCameraUpdate(distributed_clock::time_point t,
                            Eigen::Vector3d board_to_camera_rotation,
+                           Eigen::Vector3d board_to_camera_translation,
                            Eigen::Quaternion<double> imu_to_world_rotation,
-                           Eigen::Affine3d imu_to_world) override {
-    raw_camera_x_.emplace_back(board_to_camera_rotation(0, 0));
-    raw_camera_y_.emplace_back(board_to_camera_rotation(1, 0));
-    raw_camera_z_.emplace_back(board_to_camera_rotation(2, 0));
+                           Eigen::Affine3d imu_to_world,
+                           double turret_angle) override {
+    board_to_camera_rotations_.emplace_back(board_to_camera_rotation);
+    board_to_camera_translations_.emplace_back(board_to_camera_translation);
 
-    Eigen::Matrix<double, 3, 1> rotation_vector =
-        frc971::controls::ToRotationVectorFromQuaternion(imu_to_world_rotation);
     camera_times_.emplace_back(
         chrono::duration<double>(t.time_since_epoch()).count());
 
-    Eigen::Matrix<double, 3, 1> camera_error =
+    raw_camera_rot_x_.emplace_back(board_to_camera_rotation(0, 0));
+    raw_camera_rot_y_.emplace_back(board_to_camera_rotation(1, 0));
+    raw_camera_rot_z_.emplace_back(board_to_camera_rotation(2, 0));
+
+    raw_camera_trans_x_.emplace_back(board_to_camera_translation(0, 0));
+    raw_camera_trans_y_.emplace_back(board_to_camera_translation(1, 0));
+    raw_camera_trans_z_.emplace_back(board_to_camera_translation(2, 0));
+
+    Eigen::Matrix<double, 3, 1> rotation_vector =
+        frc971::controls::ToRotationVectorFromQuaternion(imu_to_world_rotation);
+    imu_rot_x_.emplace_back(rotation_vector(0, 0));
+    imu_rot_y_.emplace_back(rotation_vector(1, 0));
+    imu_rot_z_.emplace_back(rotation_vector(2, 0));
+
+    Eigen::Matrix<double, 3, 1> rotation_error =
         frc971::controls::ToRotationVectorFromQuaternion(
             imu_to_world_rotation.inverse() * orientation());
 
-    camera_x_.emplace_back(rotation_vector(0, 0));
-    camera_y_.emplace_back(rotation_vector(1, 0));
-    camera_z_.emplace_back(rotation_vector(2, 0));
+    rotation_error_x_.emplace_back(rotation_error(0, 0));
+    rotation_error_y_.emplace_back(rotation_error(1, 0));
+    rotation_error_z_.emplace_back(rotation_error(2, 0));
 
-    camera_error_x_.emplace_back(camera_error(0, 0));
-    camera_error_y_.emplace_back(camera_error(1, 0));
-    camera_error_z_.emplace_back(camera_error(2, 0));
+    Eigen::Matrix<double, 3, 1> imu_pos = get_x_hat().block<3, 1>(0, 0);
+    Eigen::Translation3d T_world_imu(imu_pos);
+    Eigen::Affine3d H_world_imu = T_world_imu * orientation();
+    Eigen::Affine3d H_error = imu_to_world.inverse() * H_world_imu;
 
-    const Eigen::Vector3d world_gravity =
+    Eigen::Matrix<double, 3, 1> translation_error = H_error.translation();
+    translation_error_x_.emplace_back(translation_error(0, 0));
+    translation_error_y_.emplace_back(translation_error(1, 0));
+    translation_error_z_.emplace_back(translation_error(2, 0));
+
+    const Eigen::Vector3d accel_minus_gravity =
         imu_to_world_rotation * last_accel_ -
         Eigen::Vector3d(0, 0, kGravity) * gravity_scalar();
 
-    const Eigen::Vector3d camera_position =
-        imu_to_world * Eigen::Vector3d::Zero();
+    accel_minus_gravity_x_.emplace_back(accel_minus_gravity.x());
+    accel_minus_gravity_y_.emplace_back(accel_minus_gravity.y());
+    accel_minus_gravity_z_.emplace_back(accel_minus_gravity.z());
 
-    world_gravity_x_.emplace_back(world_gravity.x());
-    world_gravity_y_.emplace_back(world_gravity.y());
-    world_gravity_z_.emplace_back(world_gravity.z());
+    const Eigen::Vector3d imu_position = imu_to_world * Eigen::Vector3d::Zero();
 
-    camera_position_x_.emplace_back(camera_position.x());
-    camera_position_y_.emplace_back(camera_position.y());
-    camera_position_z_.emplace_back(camera_position.z());
+    imu_position_x_.emplace_back(imu_position.x());
+    imu_position_y_.emplace_back(imu_position.y());
+    imu_position_z_.emplace_back(imu_position.z());
+
+    turret_angles_from_camera_.emplace_back(turret_angle);
+    imu_to_world_save_.emplace_back(imu_to_world);
+  }
+
+  void ObserveTurretUpdate(distributed_clock::time_point t,
+                           Eigen::Vector2d turret_state) override {
+    turret_times_.emplace_back(
+        chrono::duration<double>(t.time_since_epoch()).count());
+    turret_angles_.emplace_back(turret_state(0));
   }
 
   std::vector<double> camera_times_;
-  std::vector<double> camera_x_;
-  std::vector<double> camera_y_;
-  std::vector<double> camera_z_;
-  std::vector<double> raw_camera_x_;
-  std::vector<double> raw_camera_y_;
-  std::vector<double> raw_camera_z_;
-  std::vector<double> camera_error_x_;
-  std::vector<double> camera_error_y_;
-  std::vector<double> camera_error_z_;
+  std::vector<double> imu_rot_x_;
+  std::vector<double> imu_rot_y_;
+  std::vector<double> imu_rot_z_;
+  std::vector<double> raw_camera_rot_x_;
+  std::vector<double> raw_camera_rot_y_;
+  std::vector<double> raw_camera_rot_z_;
+  std::vector<double> raw_camera_trans_x_;
+  std::vector<double> raw_camera_trans_y_;
+  std::vector<double> raw_camera_trans_z_;
+  std::vector<double> rotation_error_x_;
+  std::vector<double> rotation_error_y_;
+  std::vector<double> rotation_error_z_;
+  std::vector<double> translation_error_x_;
+  std::vector<double> translation_error_y_;
+  std::vector<double> translation_error_z_;
+  std::vector<Eigen::Vector3d> board_to_camera_rotations_;
+  std::vector<Eigen::Vector3d> board_to_camera_translations_;
 
-  std::vector<double> world_gravity_x_;
-  std::vector<double> world_gravity_y_;
-  std::vector<double> world_gravity_z_;
-  std::vector<double> imu_x_;
-  std::vector<double> imu_y_;
-  std::vector<double> imu_z_;
-  std::vector<double> camera_position_x_;
-  std::vector<double> camera_position_y_;
-  std::vector<double> camera_position_z_;
+  std::vector<double> turret_angles_from_camera_;
+  std::vector<Eigen::Affine3d> imu_to_world_save_;
+
+  std::vector<double> imu_position_x_;
+  std::vector<double> imu_position_y_;
+  std::vector<double> imu_position_z_;
 
   std::vector<double> imu_times_;
-  std::vector<double> imu_ratex_;
-  std::vector<double> imu_ratey_;
-  std::vector<double> imu_ratez_;
+  std::vector<double> imu_rate_x_;
+  std::vector<double> imu_rate_y_;
+  std::vector<double> imu_rate_z_;
+  std::vector<double> accel_minus_gravity_x_;
+  std::vector<double> accel_minus_gravity_y_;
+  std::vector<double> accel_minus_gravity_z_;
+  std::vector<double> imu_accel_x_;
+  std::vector<double> imu_accel_y_;
+  std::vector<double> imu_accel_z_;
+
+  std::vector<double> turret_times_;
+  std::vector<double> turret_angles_;
 
   std::vector<double> times_;
   std::vector<Eigen::Matrix<double, 6, 1>> x_hats_;
@@ -549,6 +791,8 @@
                   const S *const pivot_to_imu_translation_ptr,
                   const S *const gravity_scalar_ptr,
                   const S *const accelerometer_bias_ptr, S *residual) const {
+    const aos::monotonic_clock::time_point start_time =
+        aos::monotonic_clock::now();
     Eigen::Quaternion<S> initial_orientation(
         initial_orientation_ptr[3], initial_orientation_ptr[0],
         initial_orientation_ptr[1], initial_orientation_ptr[2]);
@@ -585,18 +829,32 @@
         pivot_to_imu_translation, *gravity_scalar_ptr, accelerometer_bias);
     data->ReviewData(&filter);
 
+    // Since the angular error scale is bounded by 1 (quaternion, so unit
+    // vector, scaled by sin(alpha)), I found it necessary to scale the
+    // angular error to have it properly balance with the translational error
+    double ang_error_scale = 5.0;
     for (size_t i = 0; i < filter.num_errors(); ++i) {
-      residual[3 * i + 0] = filter.errorx(i);
-      residual[3 * i + 1] = filter.errory(i);
-      residual[3 * i + 2] = filter.errorz(i);
+      residual[3 * i + 0] = ang_error_scale * filter.errorx(i);
+      residual[3 * i + 1] = ang_error_scale * filter.errory(i);
+      residual[3 * i + 2] = ang_error_scale * filter.errorz(i);
     }
 
+    double trans_error_scale = 1.0;
     for (size_t i = 0; i < filter.num_perrors(); ++i) {
-      residual[3 * filter.num_errors() + 3 * i + 0] = filter.errorpx(i);
-      residual[3 * filter.num_errors() + 3 * i + 1] = filter.errorpy(i);
-      residual[3 * filter.num_errors() + 3 * i + 2] = filter.errorpz(i);
+      residual[3 * filter.num_errors() + 3 * i + 0] =
+          trans_error_scale * filter.errorpx(i);
+      residual[3 * filter.num_errors() + 3 * i + 1] =
+          trans_error_scale * filter.errorpy(i);
+      residual[3 * filter.num_errors() + 3 * i + 2] =
+          trans_error_scale * filter.errorpz(i);
     }
 
+    LOG(INFO) << "Cost function calc took "
+              << chrono::duration<double>(aos::monotonic_clock::now() -
+                                          start_time)
+                     .count()
+              << " seconds";
+
     return true;
   }
 };
@@ -630,6 +888,8 @@
   }
 
   {
+    // The turret's Z rotation is redundant with the camera's mounting z
+    // rotation since it's along the rotation axis.
     ceres::CostFunction *turret_z_cost_function =
         new ceres::AutoDiffCostFunction<PenalizeQuaternionZ, 1, 4>(
             new PenalizeQuaternionZ());
@@ -639,7 +899,8 @@
   }
 
   if (calibration_parameters->has_pivot) {
-    // Constrain Z.
+    // Constrain Z since it's along the rotation axis and therefore
+    // redundant.
     problem.SetParameterization(
         calibration_parameters->pivot_to_imu_translation.data(),
         new ceres::SubsetParameterization(3, {2}));
@@ -657,6 +918,9 @@
       calibration_parameters->pivot_to_camera.coeffs().data(),
       quaternion_local_parameterization);
   problem.SetParameterization(
+      calibration_parameters->pivot_to_imu.coeffs().data(),
+      quaternion_local_parameterization);
+  problem.SetParameterization(
       calibration_parameters->board_to_world.coeffs().data(),
       quaternion_local_parameterization);
   for (int i = 0; i < 3; ++i) {
@@ -678,40 +942,13 @@
   ceres::Solver::Options options;
   options.minimizer_progress_to_stdout = true;
   options.gradient_tolerance = 1e-12;
-  options.function_tolerance = 1e-16;
+  options.function_tolerance = 1e-6;
   options.parameter_tolerance = 1e-12;
   ceres::Solver::Summary summary;
   Solve(options, &problem, &summary);
   LOG(INFO) << summary.FullReport();
-
-  LOG(INFO) << "initial_orientation "
-            << calibration_parameters->initial_orientation.coeffs().transpose();
-  LOG(INFO) << "pivot_to_imu "
-            << calibration_parameters->pivot_to_imu.coeffs().transpose();
-  LOG(INFO) << "pivot_to_imu(rotation) "
-            << frc971::controls::ToRotationVectorFromQuaternion(
-                   calibration_parameters->pivot_to_imu)
-                   .transpose();
-  LOG(INFO) << "pivot_to_camera "
-            << calibration_parameters->pivot_to_camera.coeffs().transpose();
-  LOG(INFO) << "pivot_to_camera(rotation) "
-            << frc971::controls::ToRotationVectorFromQuaternion(
-                   calibration_parameters->pivot_to_camera)
-                   .transpose();
-  LOG(INFO) << "gyro_bias " << calibration_parameters->gyro_bias.transpose();
-  LOG(INFO) << "board_to_world "
-            << calibration_parameters->board_to_world.coeffs().transpose();
-  LOG(INFO) << "board_to_world(rotation) "
-            << frc971::controls::ToRotationVectorFromQuaternion(
-                   calibration_parameters->board_to_world)
-                   .transpose();
-  LOG(INFO) << "pivot_to_imu_translation "
-            << calibration_parameters->pivot_to_imu_translation.transpose();
-  LOG(INFO) << "pivot_to_camera_translation "
-            << calibration_parameters->pivot_to_camera_translation.transpose();
-  LOG(INFO) << "gravity " << kGravity * calibration_parameters->gravity_scalar;
-  LOG(INFO) << "accelerometer bias "
-            << calibration_parameters->accelerometer_bias.transpose();
+  LOG(INFO) << "Solution is " << (summary.IsSolutionUsable() ? "" : "NOT ")
+            << "usable";
 }
 
 void Plot(const CalibrationData &data,
@@ -730,5 +967,21 @@
   filter.Plot();
 }
 
+void Visualize(const CalibrationData &data,
+               const CalibrationParameters &calibration_parameters) {
+  PoseFilter filter(calibration_parameters.initial_orientation,
+                    calibration_parameters.pivot_to_camera,
+                    calibration_parameters.pivot_to_imu,
+                    calibration_parameters.gyro_bias,
+                    calibration_parameters.initial_state,
+                    calibration_parameters.board_to_world,
+                    calibration_parameters.pivot_to_camera_translation,
+                    calibration_parameters.pivot_to_imu_translation,
+                    calibration_parameters.gravity_scalar,
+                    calibration_parameters.accelerometer_bias);
+  data.ReviewData(&filter);
+  filter.Visualize(calibration_parameters);
+}
+
 }  // namespace vision
 }  // namespace frc971
diff --git a/frc971/vision/extrinsics_calibration.h b/frc971/vision/extrinsics_calibration.h
index b24f13f..eb35482 100644
--- a/frc971/vision/extrinsics_calibration.h
+++ b/frc971/vision/extrinsics_calibration.h
@@ -42,6 +42,11 @@
 void Plot(const CalibrationData &data,
           const CalibrationParameters &calibration_parameters);
 
+// Shows the evolution of the calibration over time by visualizing relevant
+// coordinate frames in a virtual camera view
+void Visualize(const CalibrationData &data,
+               const CalibrationParameters &calibration_parameters);
+
 }  // namespace vision
 }  // namespace frc971
 
diff --git a/frc971/vision/target_map.fbs b/frc971/vision/target_map.fbs
new file mode 100644
index 0000000..50a9d7d
--- /dev/null
+++ b/frc971/vision/target_map.fbs
@@ -0,0 +1,27 @@
+namespace frc971.vision;
+
+// Represents 3d pose of an april tag on the field.
+table TargetPoseFbs {
+  // AprilTag ID of this target
+  id:uint64 (id: 0);
+
+  // Pose of target relative to field origin.
+  // NOTE: As of now, we only solve for the 2d pose (x, y, yaw)
+  // and all other values will be 0.
+  x:double (id: 1);
+  y:double (id: 2);
+  z:double (id: 3);
+
+  roll:double (id: 4);
+  pitch:double (id: 5);
+  yaw:double (id: 6);
+}
+
+// Map of all target poses on a field.
+// This would be solved for by TargetMapper
+table TargetMap {
+  target_poses:[TargetPoseFbs] (id: 0);
+
+  // Unique name of the field
+  field_name:string (id: 1);
+}
\ No newline at end of file
diff --git a/frc971/vision/target_mapper.cc b/frc971/vision/target_mapper.cc
new file mode 100644
index 0000000..ccaa805
--- /dev/null
+++ b/frc971/vision/target_mapper.cc
@@ -0,0 +1,362 @@
+#include "frc971/vision/target_mapper.h"
+
+#include "frc971/control_loops/control_loop.h"
+#include "frc971/vision/ceres/angle_local_parameterization.h"
+#include "frc971/vision/ceres/normalize_angle.h"
+#include "frc971/vision/ceres/pose_graph_2d_error_term.h"
+#include "frc971/vision/geometry.h"
+
+DEFINE_uint64(max_num_iterations, 100,
+              "Maximum number of iterations for the ceres solver");
+
+namespace frc971::vision {
+
+Eigen::Affine3d PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d pose2d) {
+  Eigen::Affine3d H_world_pose =
+      Eigen::Translation3d(pose2d.x, pose2d.y, 0.0) *
+      Eigen::AngleAxisd(pose2d.yaw_radians, Eigen::Vector3d::UnitZ());
+  return H_world_pose;
+}
+
+ceres::examples::Pose2d PoseUtils::Affine3dToPose2d(Eigen::Affine3d H) {
+  Eigen::Vector3d T = H.translation();
+  double heading = std::atan2(H.rotation()(1, 0), H.rotation()(0, 0));
+  return ceres::examples::Pose2d{T[0], T[1],
+                                 ceres::examples::NormalizeAngle(heading)};
+}
+
+ceres::examples::Pose2d PoseUtils::ComputeRelativePose(
+    ceres::examples::Pose2d pose_1, ceres::examples::Pose2d pose_2) {
+  Eigen::Affine3d H_world_1 = Pose2dToAffine3d(pose_1);
+  Eigen::Affine3d H_world_2 = Pose2dToAffine3d(pose_2);
+
+  // Get the location of 2 in the 1 frame
+  Eigen::Affine3d H_1_2 = H_world_1.inverse() * H_world_2;
+  return Affine3dToPose2d(H_1_2);
+}
+
+ceres::examples::Pose2d PoseUtils::ComputeOffsetPose(
+    ceres::examples::Pose2d pose_1, ceres::examples::Pose2d pose_2_relative) {
+  auto H_world_1 = Pose2dToAffine3d(pose_1);
+  auto H_1_2 = Pose2dToAffine3d(pose_2_relative);
+  auto H_world_2 = H_world_1 * H_1_2;
+
+  return Affine3dToPose2d(H_world_2);
+}
+
+namespace {
+double ExponentiatedSinTerm(double theta) {
+  return (theta == 0.0 ? 1.0 : std::sin(theta) / theta);
+}
+
+double ExponentiatedCosTerm(double theta) {
+  return (theta == 0.0 ? 0.0 : (1 - std::cos(theta)) / theta);
+}
+}  // namespace
+
+ceres::examples::Pose2d DataAdapter::InterpolatePose(
+    const TimestampedPose &pose_start, const TimestampedPose &pose_end,
+    aos::distributed_clock::time_point time) {
+  auto delta_pose =
+      PoseUtils::ComputeRelativePose(pose_start.pose, pose_end.pose);
+  // Time from start of period, on the scale 0-1 where 1 is the end.
+  double interpolation_scalar =
+      static_cast<double>((time - pose_start.time).count()) /
+      static_cast<double>((pose_end.time - pose_start.time).count());
+
+  double theta = delta_pose.yaw_radians;
+  // Take the log of the transformation matrix:
+  // https://mathoverflow.net/questions/118533/how-to-compute-se2-group-exponential-and-logarithm
+  StdFormLine dx_line = {.a = ExponentiatedSinTerm(theta),
+                         .b = -ExponentiatedCosTerm(theta),
+                         .c = delta_pose.x};
+  StdFormLine dy_line = {.a = ExponentiatedCosTerm(theta),
+                         .b = ExponentiatedSinTerm(theta),
+                         .c = delta_pose.y};
+
+  std::optional<cv::Point2d> solution = dx_line.Intersection(dy_line);
+  CHECK(solution.has_value());
+
+  // Re-exponentiate with the new values scaled by the interpolation scalar to
+  // get an interpolated tranformation matrix
+  double a = solution->x * interpolation_scalar;
+  double b = solution->y * interpolation_scalar;
+  double alpha = theta * interpolation_scalar;
+
+  ceres::examples::Pose2d interpolated_pose = {
+      .x = a * ExponentiatedSinTerm(theta) - b * ExponentiatedCosTerm(theta),
+      .y = a * ExponentiatedCosTerm(theta) + b * ExponentiatedSinTerm(theta),
+      .yaw_radians = alpha};
+
+  return PoseUtils::ComputeOffsetPose(pose_start.pose, interpolated_pose);
+}  // namespace frc971::vision
+
+std::pair<std::vector<ceres::examples::Constraint2d>,
+          std::vector<ceres::examples::Pose2d>>
+DataAdapter::MatchTargetDetections(
+    const std::vector<TimestampedPose> &timestamped_robot_poses,
+    const std::vector<TimestampedDetection> &timestamped_target_detections) {
+  // Interpolate robot poses
+  std::map<aos::distributed_clock::time_point, ceres::examples::Pose2d>
+      interpolated_poses;
+
+  CHECK_GT(timestamped_robot_poses.size(), 1ul)
+      << "Need more than 1 robot pose";
+  auto robot_pose_it = timestamped_robot_poses.begin();
+  for (const auto &timestamped_detection : timestamped_target_detections) {
+    aos::distributed_clock::time_point target_time = timestamped_detection.time;
+    // Find the robot point right before this localization
+    while (robot_pose_it->time > target_time ||
+           (robot_pose_it + 1)->time <= target_time) {
+      robot_pose_it++;
+      CHECK(robot_pose_it < timestamped_robot_poses.end() - 1)
+          << "Need a robot pose before and after every target detection";
+    }
+
+    auto start = robot_pose_it;
+    auto end = robot_pose_it + 1;
+    interpolated_poses.emplace(target_time,
+                               InterpolatePose(*start, *end, target_time));
+  }
+
+  // Match consecutive detections
+  std::vector<ceres::examples::Constraint2d> target_constraints;
+  std::vector<ceres::examples::Pose2d> robot_delta_poses;
+
+  auto last_detection = timestamped_target_detections[0];
+  auto last_robot_pose =
+      interpolated_poses[timestamped_target_detections[0].time];
+
+  for (auto it = timestamped_target_detections.begin() + 1;
+       it < timestamped_target_detections.end(); it++) {
+    // Skip two consecutive detections of the same target, because the solver
+    // doesn't allow this
+    if (it->id == last_detection.id) {
+      continue;
+    }
+
+    auto robot_pose = interpolated_poses[it->time];
+    auto robot_delta_pose =
+        PoseUtils::ComputeRelativePose(last_robot_pose, robot_pose);
+    auto confidence = ComputeConfidence(last_detection.time, it->time);
+
+    target_constraints.emplace_back(ComputeTargetConstraint(
+        last_detection, PoseUtils::Pose2dToAffine3d(robot_delta_pose), *it,
+        confidence));
+    robot_delta_poses.emplace_back(robot_delta_pose);
+
+    last_detection = *it;
+    last_robot_pose = robot_pose;
+  }
+
+  return {target_constraints, robot_delta_poses};
+}
+
+Eigen::Matrix3d DataAdapter::ComputeConfidence(
+    aos::distributed_clock::time_point start,
+    aos::distributed_clock::time_point end) {
+  constexpr size_t kX = 0;
+  constexpr size_t kY = 1;
+  constexpr size_t kTheta = 2;
+
+  // Uncertainty matrix between start and end
+  Eigen::Matrix3d P = Eigen::Matrix3d::Zero();
+
+  {
+    // Noise for odometry-based robot position measurements
+    Eigen::Matrix3d Q_odometry = Eigen::Matrix3d::Zero();
+    Q_odometry(kX, kX) = std::pow(0.045, 2);
+    Q_odometry(kY, kY) = std::pow(0.045, 2);
+    Q_odometry(kTheta, kTheta) = std::pow(0.01, 2);
+
+    // Add uncertainty for robot position measurements from start to end
+    int iterations = (end - start) / frc971::controls::kLoopFrequency;
+    P += static_cast<double>(iterations) * Q_odometry;
+  }
+
+  {
+    // Noise for vision-based target localizations
+    Eigen::Matrix3d Q_vision = Eigen::Matrix3d::Zero();
+    Q_vision(kX, kX) = std::pow(0.09, 2);
+    Q_vision(kY, kY) = std::pow(0.09, 2);
+    Q_vision(kTheta, kTheta) = std::pow(0.02, 2);
+
+    // Add uncertainty for the 2 vision measurements (1 at start and 1 at end)
+    P += 2.0 * Q_vision;
+  }
+
+  return P.inverse();
+}
+
+ceres::examples::Constraint2d DataAdapter::ComputeTargetConstraint(
+    const TimestampedDetection &target_detection_start,
+    const Eigen::Affine3d &H_robotstart_robotend,
+    const TimestampedDetection &target_detection_end,
+    const Eigen::Matrix3d &confidence) {
+  // Compute the relative pose (constraint) between the two targets
+  Eigen::Affine3d H_targetstart_targetend =
+      target_detection_start.H_robot_target.inverse() * H_robotstart_robotend *
+      target_detection_end.H_robot_target;
+  ceres::examples::Pose2d target_constraint =
+      PoseUtils::Affine3dToPose2d(H_targetstart_targetend);
+
+  return ceres::examples::Constraint2d{
+      target_detection_start.id,     target_detection_end.id,
+      target_constraint.x,           target_constraint.y,
+      target_constraint.yaw_radians, confidence};
+}
+
+TargetMapper::TargetMapper(
+    std::string_view target_poses_path,
+    std::vector<ceres::examples::Constraint2d> target_constraints)
+    : target_constraints_(target_constraints) {
+  aos::FlatbufferDetachedBuffer<TargetMap> target_map =
+      aos::JsonFileToFlatbuffer<TargetMap>(target_poses_path);
+  for (const auto *target_pose_fbs : *target_map.message().target_poses()) {
+    target_poses_[target_pose_fbs->id()] = ceres::examples::Pose2d{
+        target_pose_fbs->x(), target_pose_fbs->y(), target_pose_fbs->yaw()};
+  }
+}
+
+TargetMapper::TargetMapper(
+    std::map<TargetId, ceres::examples::Pose2d> target_poses,
+    std::vector<ceres::examples::Constraint2d> target_constraints)
+    : target_poses_(target_poses), target_constraints_(target_constraints) {}
+
+std::optional<TargetMapper::TargetPose> TargetMapper::GetTargetPoseById(
+    std::vector<TargetMapper::TargetPose> target_poses, TargetId target_id) {
+  for (auto target_pose : target_poses) {
+    if (target_pose.id == target_id) {
+      return target_pose;
+    }
+  }
+
+  return std::nullopt;
+}
+
+// Taken from ceres/examples/slam/pose_graph_2d/pose_graph_2d.cc
+void TargetMapper::BuildOptimizationProblem(
+    std::map<int, ceres::examples::Pose2d> *poses,
+    const std::vector<ceres::examples::Constraint2d> &constraints,
+    ceres::Problem *problem) {
+  CHECK_NOTNULL(poses);
+  CHECK_NOTNULL(problem);
+  if (constraints.empty()) {
+    LOG(WARNING) << "No constraints, no problem to optimize.";
+    return;
+  }
+
+  ceres::LossFunction *loss_function = new ceres::HuberLoss(2.0);
+  ceres::LocalParameterization *angle_local_parameterization =
+      ceres::examples::AngleLocalParameterization::Create();
+
+  for (std::vector<ceres::examples::Constraint2d>::const_iterator
+           constraints_iter = constraints.begin();
+       constraints_iter != constraints.end(); ++constraints_iter) {
+    const ceres::examples::Constraint2d &constraint = *constraints_iter;
+
+    std::map<int, ceres::examples::Pose2d>::iterator pose_begin_iter =
+        poses->find(constraint.id_begin);
+    CHECK(pose_begin_iter != poses->end())
+        << "Pose with ID: " << constraint.id_begin << " not found.";
+    std::map<int, ceres::examples::Pose2d>::iterator pose_end_iter =
+        poses->find(constraint.id_end);
+    CHECK(pose_end_iter != poses->end())
+        << "Pose with ID: " << constraint.id_end << " not found.";
+
+    const Eigen::Matrix3d sqrt_information =
+        constraint.information.llt().matrixL();
+    // Ceres will take ownership of the pointer.
+    ceres::CostFunction *cost_function =
+        ceres::examples::PoseGraph2dErrorTerm::Create(
+            constraint.x, constraint.y, constraint.yaw_radians,
+            sqrt_information);
+    problem->AddResidualBlock(
+        cost_function, loss_function, &pose_begin_iter->second.x,
+        &pose_begin_iter->second.y, &pose_begin_iter->second.yaw_radians,
+        &pose_end_iter->second.x, &pose_end_iter->second.y,
+        &pose_end_iter->second.yaw_radians);
+
+    problem->SetParameterization(&pose_begin_iter->second.yaw_radians,
+                                 angle_local_parameterization);
+    problem->SetParameterization(&pose_end_iter->second.yaw_radians,
+                                 angle_local_parameterization);
+  }
+
+  // The pose graph optimization problem has three DOFs that are not fully
+  // constrained. This is typically referred to as gauge freedom. You can apply
+  // a rigid body transformation to all the nodes and the optimization problem
+  // will still have the exact same cost. The Levenberg-Marquardt algorithm has
+  // internal damping which mitigates this issue, but it is better to properly
+  // constrain the gauge freedom. This can be done by setting one of the poses
+  // as constant so the optimizer cannot change it.
+  std::map<int, ceres::examples::Pose2d>::iterator pose_start_iter =
+      poses->begin();
+  CHECK(pose_start_iter != poses->end()) << "There are no poses.";
+  problem->SetParameterBlockConstant(&pose_start_iter->second.x);
+  problem->SetParameterBlockConstant(&pose_start_iter->second.y);
+  problem->SetParameterBlockConstant(&pose_start_iter->second.yaw_radians);
+}
+
+// Taken from ceres/examples/slam/pose_graph_2d/pose_graph_2d.cc
+bool TargetMapper::SolveOptimizationProblem(ceres::Problem *problem) {
+  CHECK_NOTNULL(problem);
+
+  ceres::Solver::Options options;
+  options.max_num_iterations = FLAGS_max_num_iterations;
+  options.linear_solver_type = ceres::SPARSE_NORMAL_CHOLESKY;
+
+  ceres::Solver::Summary summary;
+  ceres::Solve(options, problem, &summary);
+
+  LOG(INFO) << summary.FullReport() << '\n';
+
+  return summary.IsSolutionUsable();
+}
+
+void TargetMapper::Solve(std::string_view field_name,
+                         std::optional<std::string_view> output_dir) {
+  ceres::Problem problem;
+  BuildOptimizationProblem(&target_poses_, target_constraints_, &problem);
+
+  CHECK(SolveOptimizationProblem(&problem))
+      << "The solve was not successful, exiting.";
+
+  // TODO(milind): add origin to first target offset to all poses
+
+  auto map_json = MapToJson(field_name);
+  VLOG(1) << "Solved target poses: " << map_json;
+
+  if (output_dir.has_value()) {
+    std::string output_path =
+        absl::StrCat(output_dir.value(), "/", field_name, ".json");
+    LOG(INFO) << "Writing map to file: " << output_path;
+    aos::util::WriteStringToFileOrDie(output_path, map_json);
+  }
+}
+
+std::string TargetMapper::MapToJson(std::string_view field_name) const {
+  flatbuffers::FlatBufferBuilder fbb;
+
+  // Convert poses to flatbuffers
+  std::vector<flatbuffers::Offset<TargetPoseFbs>> target_poses_fbs;
+  for (const auto &[id, pose] : target_poses_) {
+    TargetPoseFbs::Builder target_pose_builder(fbb);
+    target_pose_builder.add_id(id);
+    target_pose_builder.add_x(id);
+    target_pose_builder.add_y(id);
+    target_pose_builder.add_yaw(id);
+
+    target_poses_fbs.emplace_back(target_pose_builder.Finish());
+  }
+
+  const auto field_name_offset = fbb.CreateString(field_name);
+  flatbuffers::Offset<TargetMap> target_map_offset = CreateTargetMap(
+      fbb, fbb.CreateVector(target_poses_fbs), field_name_offset);
+
+  return aos::FlatbufferToJson(
+      flatbuffers::GetMutableTemporaryPointer(fbb, target_map_offset),
+      {.multi_line = true});
+}
+
+}  // namespace frc971::vision
diff --git a/frc971/vision/target_mapper.h b/frc971/vision/target_mapper.h
new file mode 100644
index 0000000..4dbe52b
--- /dev/null
+++ b/frc971/vision/target_mapper.h
@@ -0,0 +1,144 @@
+#ifndef FRC971_VISION_TARGET_MAPPER_H_
+#define FRC971_VISION_TARGET_MAPPER_H_
+
+#include <unordered_map>
+
+#include "aos/events/simulated_event_loop.h"
+#include "frc971/vision/ceres/types.h"
+#include "frc971/vision/target_map_generated.h"
+
+namespace frc971::vision {
+
+// Estimates positions of vision targets (ex. April Tags) using
+// target detections relative to a robot (which were computed using robot
+// positions at the time of those detections). Solves SLAM problem to estimate
+// target locations using deltas between consecutive target detections.
+class TargetMapper {
+ public:
+  using TargetId = int;
+
+  struct TargetPose {
+    TargetId id;
+    // TOOD(milind): switch everything to 3d once we're more confident in 2d
+    // solving
+    ceres::examples::Pose2d pose;
+  };
+
+  // target_poses_path is the path to a TargetMap json with initial guesses for
+  // the actual locations of the targets on the field.
+  // target_constraints are the deltas between consecutive target detections,
+  // and are usually prepared by the DataAdapter class below.
+  TargetMapper(std::string_view target_poses_path,
+               std::vector<ceres::examples::Constraint2d> target_constraints);
+  // Alternate constructor for tests.
+  // Takes in the actual intial guesses instead of a file containing them
+  TargetMapper(std::map<TargetId, ceres::examples::Pose2d> target_poses,
+               std::vector<ceres::examples::Constraint2d> target_constraints);
+
+  // Solves for the target map. If output_dir is set, the map will be saved to
+  // output_dir/field_name.json
+  void Solve(std::string_view field_name,
+             std::optional<std::string_view> output_dir = std::nullopt);
+
+  // Prints target poses into a TargetMap flatbuffer json
+  std::string MapToJson(std::string_view field_name) const;
+
+  static std::optional<TargetPose> GetTargetPoseById(
+      std::vector<TargetPose> target_poses, TargetId target_id);
+
+  std::map<TargetId, ceres::examples::Pose2d> target_poses() {
+    return target_poses_;
+  }
+
+ private:
+  // Constructs the nonlinear least squares optimization problem from the
+  // pose graph constraints.
+  void BuildOptimizationProblem(
+      std::map<TargetId, ceres::examples::Pose2d> *target_poses,
+      const std::vector<ceres::examples::Constraint2d> &constraints,
+      ceres::Problem *problem);
+
+  // Returns true if the solve was successful.
+  bool SolveOptimizationProblem(ceres::Problem *problem);
+
+  std::map<TargetId, ceres::examples::Pose2d> target_poses_;
+  std::vector<ceres::examples::Constraint2d> target_constraints_;
+};
+
+// Utility functions for dealing with ceres::examples::Pose2d structs
+class PoseUtils {
+ public:
+  // Embeds a 2d pose into a 3d affine transformation to be used in 3d
+  // computation
+  static Eigen::Affine3d Pose2dToAffine3d(ceres::examples::Pose2d pose2d);
+  // Assumes only x and y translation, and only z rotation (yaw)
+  static ceres::examples::Pose2d Affine3dToPose2d(Eigen::Affine3d H);
+
+  // Computes pose_2 relative to pose_1. This is equivalent to (pose_1^-1 *
+  // pose_2)
+  static ceres::examples::Pose2d ComputeRelativePose(
+      ceres::examples::Pose2d pose_1, ceres::examples::Pose2d pose_2);
+
+  // Computes pose_2 given a pose_1 and pose_2 relative to pose_1. This is
+  // equivalent to (pose_1 * pose_2_relative)
+  static ceres::examples::Pose2d ComputeOffsetPose(
+      ceres::examples::Pose2d pose_1, ceres::examples::Pose2d pose_2_relative);
+};
+
+// Transforms robot position and target detection data into target constraints
+// to be used for mapping. Interpolates continous-time data, converting it to
+// discrete detection time steps.
+class DataAdapter {
+ public:
+  // Pairs pose with a time point
+  struct TimestampedPose {
+    aos::distributed_clock::time_point time;
+    ceres::examples::Pose2d pose;
+  };
+
+  // Pairs target detection with a time point
+  struct TimestampedDetection {
+    aos::distributed_clock::time_point time;
+    // Pose of target relative to robot
+    Eigen::Affine3d H_robot_target;
+    TargetMapper::TargetId id;
+  };
+
+  // Pairs consecutive target detections into constraints, and interpolates
+  // robot poses based on time points to compute motion between detections. This
+  // prepares data to be used by TargetMapper. Also returns vector of delta
+  // robot poses corresponding to each constraint, to be used for testing.
+  //
+  // Assumes both inputs are in chronological order.
+  static std::pair<std::vector<ceres::examples::Constraint2d>,
+                   std::vector<ceres::examples::Pose2d>>
+  MatchTargetDetections(
+      const std::vector<TimestampedPose> &timestamped_robot_poses,
+      const std::vector<TimestampedDetection> &timestamped_target_detections);
+
+  // Computes inverse of covariance matrix, assuming there was a target
+  // detection between robot movement over the given time period. Ceres calls
+  // this matrix the "information"
+  static Eigen::Matrix3d ComputeConfidence(
+      aos::distributed_clock::time_point start,
+      aos::distributed_clock::time_point end);
+
+ private:
+  static ceres::examples::Pose2d InterpolatePose(
+      const TimestampedPose &pose_start, const TimestampedPose &pose_end,
+      aos::distributed_clock::time_point time);
+
+  // Computes the constraint between the start and end pose of the targets: the
+  // relative pose between the start and end target locations in the frame of
+  // the start target. Takes into account the robot motion in the time between
+  // the two detections.
+  static ceres::examples::Constraint2d ComputeTargetConstraint(
+      const TimestampedDetection &target_detection_start,
+      const Eigen::Affine3d &H_robotstart_robotend,
+      const TimestampedDetection &target_detection_end,
+      const Eigen::Matrix3d &confidence);
+};
+
+}  // namespace frc971::vision
+
+#endif  // FRC971_VISION_TARGET_MAPPER_H_
diff --git a/frc971/vision/target_mapper_test.cc b/frc971/vision/target_mapper_test.cc
new file mode 100644
index 0000000..f56cd8d
--- /dev/null
+++ b/frc971/vision/target_mapper_test.cc
@@ -0,0 +1,414 @@
+#include "frc971/vision/target_mapper.h"
+
+#include <random>
+
+#include "aos/events/simulated_event_loop.h"
+#include "aos/testing/random_seed.h"
+#include "glog/logging.h"
+#include "gtest/gtest.h"
+
+namespace frc971::vision {
+
+namespace {
+constexpr double kToleranceMeters = 0.05;
+constexpr double kToleranceRadians = 0.05;
+constexpr std::string_view kFieldName = "test";
+}  // namespace
+
+#define EXPECT_POSE_NEAR(pose1, pose2)             \
+  EXPECT_NEAR(pose1.x, pose2.x, kToleranceMeters); \
+  EXPECT_NEAR(pose1.y, pose2.y, kToleranceMeters); \
+  EXPECT_NEAR(pose1.yaw_radians, pose2.yaw_radians, kToleranceRadians);
+
+#define EXPECT_POSE_EQ(pose1, pose2)  \
+  EXPECT_DOUBLE_EQ(pose1.x, pose2.x); \
+  EXPECT_DOUBLE_EQ(pose1.y, pose2.y); \
+  EXPECT_DOUBLE_EQ(pose1.yaw_radians, pose2.yaw_radians);
+
+#define EXPECT_BETWEEN_EXCLUSIVE(value, a, b) \
+  {                                           \
+    auto low = std::min(a, b);                \
+    auto high = std::max(a, b);               \
+    EXPECT_GT(value, low);                    \
+    EXPECT_LT(value, high);                   \
+  }
+
+namespace {
+// Expects angles to be normalized
+double DeltaAngle(double a, double b) {
+  double delta = std::abs(a - b);
+  return std::min(delta, (2.0 * M_PI) - delta);
+}
+}  // namespace
+
+// Expects angles to be normalized
+#define EXPECT_ANGLE_BETWEEN_EXCLUSIVE(theta, a, b)  \
+  EXPECT_LT(DeltaAngle(a, theta), DeltaAngle(a, b)); \
+  EXPECT_LT(DeltaAngle(b, theta), DeltaAngle(a, b));
+
+#define EXPECT_POSE_IN_RANGE(interpolated_pose, pose_start, pose_end)      \
+  EXPECT_BETWEEN_EXCLUSIVE(interpolated_pose.x, pose_start.x, pose_end.x); \
+  EXPECT_BETWEEN_EXCLUSIVE(interpolated_pose.y, pose_start.y, pose_end.y); \
+  EXPECT_ANGLE_BETWEEN_EXCLUSIVE(interpolated_pose.yaw_radians,            \
+                                 pose_start.yaw_radians,                   \
+                                 pose_end.yaw_radians);
+
+// Both confidence matrixes should have the same dimensions and be square
+#define EXPECT_CONFIDENCE_GT(confidence1, confidence2) \
+  {                                                    \
+    ASSERT_EQ(confidence1.rows(), confidence2.rows()); \
+    ASSERT_EQ(confidence1.rows(), confidence1.cols()); \
+    ASSERT_EQ(confidence2.rows(), confidence2.cols()); \
+    for (size_t i = 0; i < confidence1.rows(); i++) {  \
+      EXPECT_GT(confidence1(i, i), confidence2(i, i)); \
+    }                                                  \
+  }
+
+namespace {
+ceres::examples::Pose2d MakePose(double x, double y, double yaw_radians) {
+  return ceres::examples::Pose2d{x, y, yaw_radians};
+}
+
+bool TargetIsInView(TargetMapper::TargetPose target_detection) {
+  // And check if it is within the fov of the robot /
+  // camera, assuming camera is pointing in the
+  // positive x-direction of the robot
+  double angle_to_target =
+      atan2(target_detection.pose.y, target_detection.pose.x);
+
+  // Simulated camera field of view, in radians
+  constexpr double kCameraFov = M_PI_2;
+  if (fabs(angle_to_target) <= kCameraFov / 2.0) {
+    VLOG(2) << "Found target in view, based on T = " << target_detection.pose.x
+            << ", " << target_detection.pose.y << " with angle "
+            << angle_to_target;
+    return true;
+  } else {
+    return false;
+  }
+}
+
+aos::distributed_clock::time_point TimeInMs(size_t ms) {
+  return aos::distributed_clock::time_point(std::chrono::milliseconds(ms));
+}
+
+}  // namespace
+
+TEST(DataAdapterTest, Interpolation) {
+  std::vector<DataAdapter::TimestampedPose> timestamped_robot_poses = {
+      {TimeInMs(0), ceres::examples::Pose2d{1.0, 2.0, 0.0}},
+      {TimeInMs(5), ceres::examples::Pose2d{1.0, 2.0, 0.0}},
+      {TimeInMs(10), ceres::examples::Pose2d{3.0, 1.0, M_PI_2}},
+      {TimeInMs(15), ceres::examples::Pose2d{5.0, -2.0, -M_PI}},
+      {TimeInMs(20), ceres::examples::Pose2d{5.0, -2.0, -M_PI}},
+      {TimeInMs(25), ceres::examples::Pose2d{10.0, -32.0, M_PI_2}},
+      {TimeInMs(30), ceres::examples::Pose2d{-15.0, 12.0, 0.0}},
+      {TimeInMs(35), ceres::examples::Pose2d{-15.0, 12.0, 0.0}}};
+  std::vector<DataAdapter::TimestampedDetection> timestamped_target_detections =
+      {{TimeInMs(5),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{5.0, -4.0, 0.0}),
+        0},
+       {TimeInMs(9),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{5.0, -4.0, 0.0}),
+        1},
+       {TimeInMs(9),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{5.0, -4.0, 0.0}),
+        2},
+       {TimeInMs(15),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{5.0, -4.0, 0.0}),
+        0},
+       {TimeInMs(16),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{5.0, -4.0, 0.0}),
+        2},
+       {TimeInMs(27),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{5.0, -4.0, 0.0}),
+        1}};
+  auto [target_constraints, robot_delta_poses] =
+      DataAdapter::MatchTargetDetections(timestamped_robot_poses,
+                                         timestamped_target_detections);
+
+  // Check that target constraints got inserted in the
+  // correct spots
+  EXPECT_EQ(target_constraints.size(),
+            timestamped_target_detections.size() - 1);
+  for (auto it = target_constraints.begin(); it < target_constraints.end();
+       it++) {
+    auto timestamped_it = timestamped_target_detections.begin() +
+                          (it - target_constraints.begin());
+    EXPECT_EQ(it->id_begin, timestamped_it->id);
+    EXPECT_EQ(it->id_end, (timestamped_it + 1)->id);
+  }
+
+  // Check that poses were interpolated correctly.
+  // Keep track of the computed robot pose by adding the delta poses
+  auto computed_robot_pose = timestamped_robot_poses[1].pose;
+
+  computed_robot_pose =
+      PoseUtils::ComputeOffsetPose(computed_robot_pose, robot_delta_poses[0]);
+  EXPECT_POSE_IN_RANGE(computed_robot_pose, timestamped_robot_poses[1].pose,
+                       timestamped_robot_poses[2].pose);
+
+  computed_robot_pose =
+      PoseUtils::ComputeOffsetPose(computed_robot_pose, robot_delta_poses[1]);
+  EXPECT_POSE_IN_RANGE(computed_robot_pose, timestamped_robot_poses[1].pose,
+                       timestamped_robot_poses[2].pose);
+  EXPECT_POSE_EQ(robot_delta_poses[1], MakePose(0.0, 0.0, 0.0));
+
+  computed_robot_pose =
+      PoseUtils::ComputeOffsetPose(computed_robot_pose, robot_delta_poses[2]);
+  EXPECT_POSE_EQ(computed_robot_pose, timestamped_robot_poses[3].pose);
+
+  computed_robot_pose =
+      PoseUtils::ComputeOffsetPose(computed_robot_pose, robot_delta_poses[3]);
+  EXPECT_POSE_EQ(computed_robot_pose, timestamped_robot_poses[4].pose);
+
+  computed_robot_pose =
+      PoseUtils::ComputeOffsetPose(computed_robot_pose, robot_delta_poses[4]);
+  EXPECT_POSE_IN_RANGE(computed_robot_pose, timestamped_robot_poses[5].pose,
+                       timestamped_robot_poses[6].pose);
+
+  // Check the confidence matrices. Don't check the actual values
+  // in case the constants change, just check the confidence of contraints
+  // relative to each other, as constraints over longer time periods should have
+  // lower confidence.
+  const auto confidence_0ms =
+      DataAdapter::ComputeConfidence(TimeInMs(0), TimeInMs(0));
+  const auto confidence_1ms =
+      DataAdapter::ComputeConfidence(TimeInMs(0), TimeInMs(1));
+  const auto confidence_4ms =
+      DataAdapter::ComputeConfidence(TimeInMs(0), TimeInMs(4));
+  const auto confidence_6ms =
+      DataAdapter::ComputeConfidence(TimeInMs(0), TimeInMs(6));
+  const auto confidence_11ms =
+      DataAdapter::ComputeConfidence(TimeInMs(0), TimeInMs(11));
+
+  // Check relative magnitude of different confidences.
+  // Confidences for 0-5ms, 5-10ms, and 10-15ms periods are equal
+  // because they fit within one control loop iteration.
+  EXPECT_EQ(confidence_0ms, confidence_1ms);
+  EXPECT_EQ(confidence_1ms, confidence_4ms);
+  EXPECT_CONFIDENCE_GT(confidence_4ms, confidence_6ms);
+  EXPECT_CONFIDENCE_GT(confidence_6ms, confidence_11ms);
+
+  // Check that confidences (information) of actual constraints are correct
+  EXPECT_EQ(target_constraints[0].information, confidence_4ms);
+  EXPECT_EQ(target_constraints[1].information, confidence_0ms);
+  EXPECT_EQ(target_constraints[2].information, confidence_6ms);
+  EXPECT_EQ(target_constraints[3].information, confidence_1ms);
+  EXPECT_EQ(target_constraints[4].information, confidence_11ms);
+}
+
+TEST(TargetMapperTest, TwoTargetsOneConstraint) {
+  std::map<TargetMapper::TargetId, ceres::examples::Pose2d> target_poses;
+  target_poses[0] = ceres::examples::Pose2d{5.0, 0.0, M_PI};
+  target_poses[1] = ceres::examples::Pose2d{-5.0, 0.0, 0.0};
+
+  std::vector<DataAdapter::TimestampedPose> timestamped_robot_poses = {
+      {TimeInMs(5), ceres::examples::Pose2d{2.0, 0.0, 0.0}},
+      {TimeInMs(10), ceres::examples::Pose2d{-1.0, 0.0, 0.0}},
+      {TimeInMs(15), ceres::examples::Pose2d{-1.0, 0.0, 0.0}}};
+  std::vector<DataAdapter::TimestampedDetection> timestamped_target_detections =
+      {{TimeInMs(5),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{3.0, 0.0, M_PI}),
+        0},
+       {TimeInMs(10),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{-4.0, 0.0, 0.0}),
+        1}};
+  auto target_constraints =
+      DataAdapter::MatchTargetDetections(timestamped_robot_poses,
+                                         timestamped_target_detections)
+          .first;
+
+  frc971::vision::TargetMapper mapper(target_poses, target_constraints);
+  mapper.Solve(kFieldName);
+
+  ASSERT_EQ(mapper.target_poses().size(), 2);
+  EXPECT_POSE_NEAR(mapper.target_poses()[0], MakePose(5.0, 0.0, M_PI));
+  EXPECT_POSE_NEAR(mapper.target_poses()[1], MakePose(-5.0, 0.0, 0.0));
+}
+
+TEST(TargetMapperTest, TwoTargetsTwoConstraints) {
+  std::map<TargetMapper::TargetId, ceres::examples::Pose2d> target_poses;
+  target_poses[0] = ceres::examples::Pose2d{5.0, 0.0, M_PI};
+  target_poses[1] = ceres::examples::Pose2d{-5.0, 0.0, -M_PI_2};
+
+  std::vector<DataAdapter::TimestampedPose> timestamped_robot_poses = {
+      {TimeInMs(5), ceres::examples::Pose2d{-1.0, 0.0, 0.0}},
+      {TimeInMs(10), ceres::examples::Pose2d{3.0, 0.0, 0.0}},
+      {TimeInMs(15), ceres::examples::Pose2d{4.0, 0.0, 0.0}},
+      {TimeInMs(20), ceres::examples::Pose2d{-1.0, 0.0, 0.0}}};
+  std::vector<DataAdapter::TimestampedDetection> timestamped_target_detections =
+      {{TimeInMs(5),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{6.0, 0.0, M_PI}),
+        0},
+       {TimeInMs(10),
+        PoseUtils::Pose2dToAffine3d(
+            ceres::examples::Pose2d{-8.0, 0.0, -M_PI_2}),
+        1},
+       {TimeInMs(15),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{1.0, 0.0, M_PI}),
+        0}};
+  auto target_constraints =
+      DataAdapter::MatchTargetDetections(timestamped_robot_poses,
+                                         timestamped_target_detections)
+          .first;
+
+  frc971::vision::TargetMapper mapper(target_poses, target_constraints);
+  mapper.Solve(kFieldName);
+
+  ASSERT_EQ(mapper.target_poses().size(), 2);
+  EXPECT_POSE_NEAR(mapper.target_poses()[0], MakePose(5.0, 0.0, M_PI));
+  EXPECT_POSE_NEAR(mapper.target_poses()[1], MakePose(-5.0, 0.0, -M_PI_2));
+}
+
+TEST(TargetMapperTest, TwoTargetsOneNoisyConstraint) {
+  std::map<TargetMapper::TargetId, ceres::examples::Pose2d> target_poses;
+  target_poses[0] = ceres::examples::Pose2d{5.0, 0.0, M_PI};
+  target_poses[1] = ceres::examples::Pose2d{-5.0, 0.0, 0.0};
+
+  std::vector<DataAdapter::TimestampedPose> timestamped_robot_poses = {
+      {TimeInMs(5), ceres::examples::Pose2d{1.99, 0.0, 0.0}},
+      {TimeInMs(10), ceres::examples::Pose2d{-1.0, 0.0, 0.0}},
+      {TimeInMs(15), ceres::examples::Pose2d{-1.01, -0.01, 0.004}}};
+  std::vector<DataAdapter::TimestampedDetection> timestamped_target_detections =
+      {{TimeInMs(5),
+        PoseUtils::Pose2dToAffine3d(
+            ceres::examples::Pose2d{3.01, 0.001, M_PI - 0.001}),
+        0},
+       {TimeInMs(10),
+        PoseUtils::Pose2dToAffine3d(ceres::examples::Pose2d{-4.01, 0.0, 0.0}),
+        1}};
+
+  auto target_constraints =
+      DataAdapter::MatchTargetDetections(timestamped_robot_poses,
+                                         timestamped_target_detections)
+          .first;
+
+  frc971::vision::TargetMapper mapper(target_poses, target_constraints);
+  mapper.Solve(kFieldName);
+
+  ASSERT_EQ(mapper.target_poses().size(), 2);
+  EXPECT_POSE_NEAR(mapper.target_poses()[0], MakePose(5.0, 0.0, M_PI));
+  EXPECT_POSE_NEAR(mapper.target_poses()[1], MakePose(-5.0, 0.0, 0.0));
+}
+
+TEST(TargetMapperTest, MultiTargetCircleMotion) {
+  // Build set of target locations wrt global origin
+  // For simplicity, do this on a grid of the field
+  double field_half_length = 7.5;  // half length of the field
+  double field_half_width = 5.0;   // half width of the field
+  std::map<TargetMapper::TargetId, ceres::examples::Pose2d> target_poses;
+  std::vector<TargetMapper::TargetPose> actual_target_poses;
+  for (int i = 0; i < 3; i++) {
+    for (int j = 0; j < 3; j++) {
+      TargetMapper::TargetId target_id = i * 3 + j;
+      TargetMapper::TargetPose target_pose{
+          target_id, ceres::examples::Pose2d{field_half_length * (1 - i),
+                                             field_half_width * (1 - j), 0.0}};
+      actual_target_poses.emplace_back(target_pose);
+      target_poses[target_id] = target_pose.pose;
+      VLOG(2) << "VERTEX_SE2 " << target_id << " " << target_pose.pose.x << " "
+              << target_pose.pose.y << " " << target_pose.pose.yaw_radians;
+    }
+  }
+
+  // Now, create a bunch of robot poses and target
+  // observations
+  size_t dt = 1;
+
+  std::vector<DataAdapter::TimestampedPose> timestamped_robot_poses;
+  std::vector<DataAdapter::TimestampedDetection> timestamped_target_detections;
+
+  constexpr size_t kTotalSteps = 100;
+  for (size_t step_count = 0; step_count < kTotalSteps; step_count++) {
+    size_t t = dt * step_count;
+    // Circle clockwise around the center of the field
+    double robot_theta = t;
+    double robot_x = (field_half_length / 2.0) * cos(robot_theta);
+    double robot_y = (-field_half_width / 2.0) * sin(robot_theta);
+
+    ceres::examples::Pose2d robot_pose{robot_x, robot_y, robot_theta};
+    for (TargetMapper::TargetPose target_pose : actual_target_poses) {
+      TargetMapper::TargetPose target_detection = {
+          .id = target_pose.id,
+          .pose = PoseUtils::ComputeRelativePose(robot_pose, target_pose.pose)};
+      if (TargetIsInView(target_detection)) {
+        // Define random generator with Gaussian
+        // distribution
+        const double mean = 0.0;
+        const double stddev = 1.0;
+        // Can play with this to see how it impacts
+        // randomness
+        constexpr double kNoiseScale = 0.01;
+        std::default_random_engine generator(aos::testing::RandomSeed());
+        std::normal_distribution<double> dist(mean, stddev);
+
+        target_detection.pose.x += dist(generator) * kNoiseScale;
+        target_detection.pose.y += dist(generator) * kNoiseScale;
+        robot_pose.x += dist(generator) * kNoiseScale;
+        robot_pose.y += dist(generator) * kNoiseScale;
+
+        auto time_point =
+            aos::distributed_clock::time_point(std::chrono::milliseconds(t));
+        timestamped_robot_poses.emplace_back(DataAdapter::TimestampedPose{
+            .time = time_point, .pose = robot_pose});
+        timestamped_target_detections.emplace_back(
+            DataAdapter::TimestampedDetection{
+                .time = time_point,
+                .H_robot_target =
+                    PoseUtils::Pose2dToAffine3d(target_detection.pose),
+                .id = target_detection.id});
+      }
+    }
+  }
+
+  {
+    // Add in a robot pose after all target poses
+    auto final_robot_pose =
+        timestamped_robot_poses[timestamped_robot_poses.size() - 1];
+    timestamped_robot_poses.emplace_back(DataAdapter::TimestampedPose{
+        .time = final_robot_pose.time + std::chrono::milliseconds(dt),
+        .pose = final_robot_pose.pose});
+  }
+
+  auto target_constraints =
+      DataAdapter::MatchTargetDetections(timestamped_robot_poses,
+                                         timestamped_target_detections)
+          .first;
+  frc971::vision::TargetMapper mapper(target_poses, target_constraints);
+  mapper.Solve(kFieldName);
+
+  for (auto [target_pose_id, mapper_target_pose] : mapper.target_poses()) {
+    TargetMapper::TargetPose actual_target_pose =
+        TargetMapper::GetTargetPoseById(actual_target_poses, target_pose_id)
+            .value();
+    EXPECT_POSE_NEAR(mapper_target_pose, actual_target_pose.pose);
+  }
+
+  //
+  // See what happens when we don't start with the
+  // correct values
+  //
+  for (auto [target_id, target_pose] : target_poses) {
+    // Skip first pose, since that needs to be correct
+    // and is fixed in the solver
+    if (target_id != 0) {
+      ceres::examples::Pose2d bad_pose{0.0, 0.0, M_PI / 2.0};
+      target_poses[target_id] = bad_pose;
+    }
+  }
+
+  frc971::vision::TargetMapper mapper_bad_poses(target_poses,
+                                                target_constraints);
+  mapper_bad_poses.Solve(kFieldName);
+
+  for (auto [target_pose_id, mapper_target_pose] :
+       mapper_bad_poses.target_poses()) {
+    TargetMapper::TargetPose actual_target_pose =
+        TargetMapper::GetTargetPoseById(actual_target_poses, target_pose_id)
+            .value();
+    EXPECT_POSE_NEAR(mapper_target_pose, actual_target_pose.pose);
+  }
+}
+
+}  // namespace frc971::vision
diff --git a/frc971/vision/visualize_robot.cc b/frc971/vision/visualize_robot.cc
new file mode 100644
index 0000000..0bbe507
--- /dev/null
+++ b/frc971/vision/visualize_robot.cc
@@ -0,0 +1,74 @@
+#include "frc971/vision/visualize_robot.h"
+#include "glog/logging.h"
+
+#include <opencv2/calib3d.hpp>
+#include <opencv2/core/eigen.hpp>
+#include <opencv2/highgui/highgui.hpp>
+#include <opencv2/imgproc.hpp>
+
+namespace frc971 {
+namespace vision {
+
+cv::Point VisualizeRobot::ProjectPoint(Eigen::Vector3d T_world_point) {
+  // Map 3D point in world coordinates to camera frame
+  Eigen::Vector3d T_camera_point = H_world_viewpoint_.inverse() * T_world_point;
+
+  cv::Vec3d T_camera_point_cv;
+  cv::eigen2cv(T_camera_point, T_camera_point_cv);
+
+  // Project 3d point in camera frame via camera intrinsics
+  cv::Mat proj_point = camera_mat_ * cv::Mat(T_camera_point_cv);
+  cv::Point projected_point(
+      proj_point.at<double>(0, 0) / proj_point.at<double>(0, 2),
+      proj_point.at<double>(0, 1) / proj_point.at<double>(0, 2));
+  return projected_point;
+}
+
+void VisualizeRobot::DrawLine(Eigen::Vector3d start3d, Eigen::Vector3d end3d) {
+  cv::Point start2d = ProjectPoint(start3d);
+  cv::Point end2d = ProjectPoint(end3d);
+
+  cv::line(image_, start2d, end2d, cv::Scalar(0, 0, 255));
+}
+
+void VisualizeRobot::DrawFrameAxes(Eigen::Affine3d H_world_target,
+                                   std::string label, double axis_scale) {
+  // Map origin to display from global (world) frame to camera frame
+  Eigen::Affine3d H_viewpoint_target =
+      H_world_viewpoint_.inverse() * H_world_target;
+
+  // Extract into OpenCV vectors
+  cv::Mat H_viewpoint_target_mat;
+  cv::eigen2cv(H_viewpoint_target.matrix(), H_viewpoint_target_mat);
+
+  // Convert to opencv R, T for using drawFrameAxes
+  cv::Vec3d rvec, tvec;
+  tvec = H_viewpoint_target_mat(cv::Rect(3, 0, 1, 3));
+  cv::Mat r_mat = H_viewpoint_target_mat(cv::Rect(0, 0, 3, 3));
+  cv::Rodrigues(r_mat, rvec);
+
+  cv::drawFrameAxes(image_, camera_mat_, dist_coeffs_, rvec, tvec, axis_scale);
+
+  if (label != "") {
+    // Grab x axis direction
+    cv::Vec3d label_offset = r_mat.col(0);
+
+    // Find 3D coordinate of point at the end of the x-axis, and project it
+    cv::Mat label_coord_res =
+        camera_mat_ * cv::Mat(tvec + label_offset * axis_scale);
+    cv::Vec3d label_coord = label_coord_res.col(0);
+    label_coord[0] = label_coord[0] / label_coord[2];
+    label_coord[1] = label_coord[1] / label_coord[2];
+    cv::putText(image_, label, cv::Point(label_coord[0], label_coord[1]),
+                cv::FONT_HERSHEY_PLAIN, 1.0, cv::Scalar(0, 0, 255));
+  }
+}
+
+void VisualizeRobot::DrawBoardOutline(Eigen::Affine3d H_world_board,
+                                      std::string label) {
+  LOG(INFO) << "Not yet implemented; drawing axes only";
+  DrawFrameAxes(H_world_board, label);
+}
+
+}  // namespace vision
+}  // namespace frc971
diff --git a/frc971/vision/visualize_robot.h b/frc971/vision/visualize_robot.h
new file mode 100644
index 0000000..391a030
--- /dev/null
+++ b/frc971/vision/visualize_robot.h
@@ -0,0 +1,65 @@
+#ifndef FRC971_VISION_VISUALIZE_ROBOT_H_
+#define FRC971_VISION_VISUALIZE_ROBOT_H_
+
+#include <opencv2/core.hpp>
+#include <opencv2/highgui.hpp>
+#include <opencv2/imgproc.hpp>
+
+#include "Eigen/Dense"
+#include "Eigen/Geometry"
+
+namespace frc971 {
+namespace vision {
+
+// Helper class to visualize the coordinate frames associated with
+// the robot Based on a virtual camera viewpoint, and camera model,
+// this class can be used to draw 3D coordinate frames in a virtual
+// camera view.
+//
+// Mostly useful just for doing all the projection calculations
+// Leverages Eigen for transforms and opencv for drawing axes
+
+class VisualizeRobot {
+ public:
+  // Set image on which to draw
+  void SetImage(cv::Mat image) { image_ = image; }
+
+  // Set the viewpoint of the camera relative to a global origin
+  void SetViewpoint(Eigen::Affine3d view_origin) {
+    H_world_viewpoint_ = view_origin;
+  }
+
+  // Set camera parameters (for projection into a virtual view)
+  void SetCameraParameters(cv::Mat camera_intrinsics) {
+    camera_mat_ = camera_intrinsics;
+  }
+
+  // Set distortion coefficients (defaults to 0)
+  void SetDistortionCoefficients(cv::Mat dist_coeffs) {
+    dist_coeffs_ = dist_coeffs;
+  }
+
+  // Helper function to project a point in 3D to the virtual image coordinates
+  cv::Point ProjectPoint(Eigen::Vector3d point3d);
+
+  // Draw a line connecting two 3D points
+  void DrawLine(Eigen::Vector3d start, Eigen::Vector3d end);
+
+  // Draw coordinate frame for a target frame relative to the world frame
+  // Axes are drawn (x,y,z) -> (red, green, blue)
+  void DrawFrameAxes(Eigen::Affine3d H_world_target, std::string label = "",
+                     double axis_scale = 0.25);
+
+  // TODO<Jim>: Need to implement this, and maybe DrawRobotOutline
+  void DrawBoardOutline(Eigen::Affine3d H_world_board, std::string label = "");
+
+  Eigen::Affine3d H_world_viewpoint_;  // Where to view the world from
+  cv::Mat image_;                      // Image to draw on
+  cv::Mat camera_mat_;   // Virtual camera intrinsics (defines fov, center)
+  cv::Mat dist_coeffs_;  // Distortion coefficients, if desired (only used in
+                         // DrawFrameAxes
+};
+}  // namespace vision
+}  // namespace frc971
+
+#endif  // FRC971_VISION_VISUALIZE_ROBOT_H_
diff --git a/frc971/vision/visualize_robot_sample.cc b/frc971/vision/visualize_robot_sample.cc
new file mode 100644
index 0000000..dc38352
--- /dev/null
+++ b/frc971/vision/visualize_robot_sample.cc
@@ -0,0 +1,72 @@
+#include "frc971/vision/visualize_robot.h"
+
+#include "aos/init.h"
+#include "aos/logging/logging.h"
+#include "glog/logging.h"
+
+#include "Eigen/Dense"
+
+#include <math.h>
+#include <opencv2/aruco.hpp>
+#include <opencv2/aruco/charuco.hpp>
+#include <opencv2/calib3d.hpp>
+#include <opencv2/core/eigen.hpp>
+#include <opencv2/highgui/highgui.hpp>
+#include <opencv2/imgproc.hpp>
+#include "aos/time/time.h"
+
+namespace frc971 {
+namespace vision {
+
+// Show / test the basics of visualizing the robot frames
+void Main(int /*argc*/, char ** /* argv */) {
+  VisualizeRobot vis_robot;
+
+  int image_width = 500;
+  cv::Mat image_mat =
+      cv::Mat::zeros(cv::Size(image_width, image_width), CV_8UC3);
+  vis_robot.SetImage(image_mat);
+
+  // 10 meters above the origin, rotated so the camera faces straight down
+  Eigen::Translation3d camera_trans(0, 0, 10.0);
+  Eigen::AngleAxisd camera_rot(M_PI, Eigen::Vector3d::UnitX());
+  Eigen::Affine3d camera_viewpoint = camera_trans * camera_rot;
+  vis_robot.SetViewpoint(camera_viewpoint);
+
+  cv::Mat camera_mat;
+  double focal_length = 1000.0;
+  double intr[] = {focal_length, 0.0,          image_width / 2.0,
+                   0.0,          focal_length, image_width / 2.0,
+                   0.0,          0.0,          1.0};
+  camera_mat = cv::Mat(3, 3, CV_64FC1, intr);
+  vis_robot.SetCameraParameters(camera_mat);
+
+  Eigen::Affine3d offset_rotate_origin(Eigen::Affine3d::Identity());
+
+  cv::Mat dist_coeffs = cv::Mat(1, 5, CV_64F, 0.0);
+  vis_robot.SetDistortionCoefficients(dist_coeffs);
+
+  // Go around the clock and plot the coordinate frame at different rotations
+  for (int i = 0; i < 12; i++) {
+    double angle = M_PI * double(i) / 6.0;
+    Eigen::Vector3d trans;
+    trans << 1.0 * cos(angle), 1.0 * sin(angle), 0.0;
+
+    offset_rotate_origin = Eigen::Translation3d(trans) *
+                           Eigen::AngleAxisd(angle, Eigen::Vector3d::UnitX());
+
+    vis_robot.DrawFrameAxes(offset_rotate_origin, std::to_string(i));
+  }
+
+  // Display the result
+  cv::imshow("Display", image_mat);
+  cv::waitKey();
+}
+}  // namespace vision
+}  // namespace frc971
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+
+  frc971::vision::Main(argc, argv);
+}
diff --git a/go.mod b/go.mod
index 09c71f4..b1720c2 100644
--- a/go.mod
+++ b/go.mod
@@ -7,27 +7,32 @@
 	github.com/golang/protobuf v1.5.2
 	github.com/google/flatbuffers v2.0.5+incompatible
 	google.golang.org/grpc v1.43.0
+	gorm.io/driver/postgres v1.3.7
+	gorm.io/gorm v1.23.5
 )
 
 require (
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/davecgh/go-spew v1.1.1
 	github.com/google/go-querystring v1.1.0 // indirect
-	github.com/jackc/pgx v3.6.2+incompatible
 	github.com/phst/runfiles v0.0.0-20220125203201-388095b3a22d
 	golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect
 	golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect
-	golang.org/x/text v0.3.6 // indirect
+	golang.org/x/text v0.3.7 // indirect
 	google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
 	google.golang.org/protobuf v1.26.0 // indirect
 )
 
 require (
-	github.com/cockroachdb/apd v1.1.0 // indirect
-	github.com/gofrs/uuid v4.0.0+incompatible // indirect
-	github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect
-	github.com/lib/pq v1.10.2 // indirect
-	github.com/pkg/errors v0.8.1 // indirect
-	github.com/shopspring/decimal v1.2.0 // indirect
-	golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
+	github.com/jackc/chunkreader/v2 v2.0.1 // indirect
+	github.com/jackc/pgconn v1.12.1 // indirect
+	github.com/jackc/pgio v1.0.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgproto3/v2 v2.3.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
+	github.com/jackc/pgtype v1.11.0 // indirect
+	github.com/jackc/pgx/v4 v4.16.1 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.4 // indirect
+	golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
 )
diff --git a/go.sum b/go.sum
index 7c08101..3082bdc 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,8 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
+github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/buildkite/go-buildkite v2.2.0+incompatible h1:yEjSu1axFC88x4dbufhgMDsEnJztPWlLiZzEvzJggXc=
 github.com/buildkite/go-buildkite v2.2.0+incompatible/go.mod h1:WTV0aX5KnQ9ofsKMg2CLUBLJNsQ0RwOEKPhrXXZWPcE=
@@ -17,6 +19,9 @@
 github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -27,6 +32,9 @@
 github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
 github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -57,41 +65,145 @@
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=
-github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
-github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
-github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
+github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
+github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
+github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
+github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
+github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
+github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
+github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
+github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
+github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
+github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
+github.com/jackc/pgconn v1.12.1 h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8=
+github.com/jackc/pgconn v1.12.1/go.mod h1:ZkhRC59Llhrq3oSfrikvwQ5NaxYExr6twkdkMLaKono=
+github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
+github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
+github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
+github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
+github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
+github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
+github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
+github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
+github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.3.0 h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y=
+github.com/jackc/pgproto3/v2 v2.3.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
+github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
+github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
+github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
+github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
+github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
+github.com/jackc/pgtype v1.11.0 h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs=
+github.com/jackc/pgtype v1.11.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
+github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
+github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
+github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
+github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
+github.com/jackc/pgx/v4 v4.16.1 h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y=
+github.com/jackc/pgx/v4 v4.16.1/go.mod h1:SIhx0D5hoADaiXZVyv+3gSm3LCIIINTVO0PficsvWGQ=
+github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.2.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas=
+github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
+github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/phst/runfiles v0.0.0-20220125203201-388095b3a22d h1:N5aMcF9W9AjW4ed+PJhA7+FjdgPa9gJ+St3mNu2tq1Q=
 github.com/phst/runfiles v0.0.0-20220125203201-388095b3a22d/go.mod h1:+oijTyzCf6Qe7sczsCOuoeX11IxZ+UkXXlhLrfyHlzg=
 github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
+github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
+github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
+github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
+go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
+go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
+go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
+go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
+go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
+golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -102,22 +214,44 @@
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
+golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -149,8 +283,18 @@
 google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/postgres v1.3.7 h1:FKF6sIMDHDEvvMF/XJvbnCl0nu6KSKUaPXevJ4r+VYQ=
+gorm.io/driver/postgres v1.3.7/go.mod h1:f02ympjIcgtHEGFMZvdgTxODZ9snAHDb4hXfigBVuNI=
+gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
+gorm.io/gorm v1.23.5 h1:TnlF26wScKSvknUC/Rn8t0NLLM22fypYBlvj1+aH6dM=
+gorm.io/gorm v1.23.5/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
diff --git a/go_deps.bzl b/go_deps.bzl
index 9d37e42..2869b5e 100644
--- a/go_deps.bzl
+++ b/go_deps.bzl
@@ -5,8 +5,8 @@
     maybe_override_go_dep(
         name = "co_honnef_go_tools",
         importpath = "honnef.co/go/tools",
-        sum = "h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs=",
-        version = "v0.0.0-20190523083050-ea95bdfd59fc",
+        sum = "h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=",
+        version = "v0.0.1-2019.2.3",
     )
     maybe_override_go_dep(
         name = "com_github_antihax_optional",
@@ -69,6 +69,18 @@
         version = "v1.1.0",
     )
     maybe_override_go_dep(
+        name = "com_github_coreos_go_systemd",
+        importpath = "github.com/coreos/go-systemd",
+        sum = "h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=",
+        version = "v0.0.0-20190719114852-fd7a80b32e1f",
+    )
+    maybe_override_go_dep(
+        name = "com_github_creack_pty",
+        importpath = "github.com/creack/pty",
+        sum = "h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A=",
+        version = "v1.1.7",
+    )
+    maybe_override_go_dep(
         name = "com_github_davecgh_go_spew",
         importpath = "github.com/davecgh/go-spew",
         sum = "h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=",
@@ -93,6 +105,24 @@
         version = "v1.0.0",
     )
     maybe_override_go_dep(
+        name = "com_github_go_kit_log",
+        importpath = "github.com/go-kit/log",
+        sum = "h1:DGJh0Sm43HbOeYDNnVZFl8BvcYVvjD5bqYJvp0REbwQ=",
+        version = "v0.1.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_go_logfmt_logfmt",
+        importpath = "github.com/go-logfmt/logfmt",
+        sum = "h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=",
+        version = "v0.5.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_go_stack_stack",
+        importpath = "github.com/go-stack/stack",
+        sum = "h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=",
+        version = "v1.8.0",
+    )
+    maybe_override_go_dep(
         name = "com_github_gofrs_uuid",
         importpath = "github.com/gofrs/uuid",
         sum = "h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=",
@@ -129,6 +159,12 @@
         version = "v1.1.0",
     )
     maybe_override_go_dep(
+        name = "com_github_google_renameio",
+        importpath = "github.com/google/renameio",
+        sum = "h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=",
+        version = "v0.1.0",
+    )
+    maybe_override_go_dep(
         name = "com_github_google_uuid",
         importpath = "github.com/google/uuid",
         sum = "h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=",
@@ -141,16 +177,118 @@
         version = "v1.16.0",
     )
     maybe_override_go_dep(
-        name = "com_github_jackc_fake",
-        importpath = "github.com/jackc/fake",
-        sum = "h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=",
-        version = "v0.0.0-20150926172116-812a484cc733",
+        name = "com_github_jackc_chunkreader",
+        importpath = "github.com/jackc/chunkreader",
+        sum = "h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=",
+        version = "v1.0.0",
     )
     maybe_override_go_dep(
-        name = "com_github_jackc_pgx",
-        importpath = "github.com/jackc/pgx",
-        sum = "h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=",
-        version = "v3.6.2+incompatible",
+        name = "com_github_jackc_chunkreader_v2",
+        importpath = "github.com/jackc/chunkreader/v2",
+        sum = "h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=",
+        version = "v2.0.1",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgconn",
+        importpath = "github.com/jackc/pgconn",
+        sum = "h1:rsDFzIpRk7xT4B8FufgpCCeyjdNpKyghZeSefViE5W8=",
+        version = "v1.12.1",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgio",
+        importpath = "github.com/jackc/pgio",
+        sum = "h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=",
+        version = "v1.0.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgmock",
+        importpath = "github.com/jackc/pgmock",
+        sum = "h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=",
+        version = "v0.0.0-20210724152146-4ad1a8207f65",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgpassfile",
+        importpath = "github.com/jackc/pgpassfile",
+        sum = "h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=",
+        version = "v1.0.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgproto3",
+        importpath = "github.com/jackc/pgproto3",
+        sum = "h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=",
+        version = "v1.1.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgproto3_v2",
+        importpath = "github.com/jackc/pgproto3/v2",
+        sum = "h1:brH0pCGBDkBW07HWlN/oSBXrmo3WB0UvZd1pIuDcL8Y=",
+        version = "v2.3.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgservicefile",
+        importpath = "github.com/jackc/pgservicefile",
+        sum = "h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=",
+        version = "v0.0.0-20200714003250-2b9c44734f2b",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgtype",
+        importpath = "github.com/jackc/pgtype",
+        sum = "h1:u4uiGPz/1hryuXzyaBhSk6dnIyyG2683olG2OV+UUgs=",
+        version = "v1.11.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_pgx_v4",
+        importpath = "github.com/jackc/pgx/v4",
+        sum = "h1:JzTglcal01DrghUqt+PmzWsZx/Yh7SC/CTQmSBMTd0Y=",
+        version = "v4.16.1",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jackc_puddle",
+        importpath = "github.com/jackc/puddle",
+        sum = "h1:gI8os0wpRXFd4FiAY2dWiqRK037tjj3t7rKFeO4X5iw=",
+        version = "v1.2.1",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jinzhu_inflection",
+        importpath = "github.com/jinzhu/inflection",
+        sum = "h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=",
+        version = "v1.0.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_jinzhu_now",
+        importpath = "github.com/jinzhu/now",
+        sum = "h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas=",
+        version = "v1.1.4",
+    )
+    maybe_override_go_dep(
+        name = "com_github_kisielk_gotool",
+        importpath = "github.com/kisielk/gotool",
+        sum = "h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=",
+        version = "v1.0.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_konsorten_go_windows_terminal_sequences",
+        importpath = "github.com/konsorten/go-windows-terminal-sequences",
+        sum = "h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=",
+        version = "v1.0.2",
+    )
+    maybe_override_go_dep(
+        name = "com_github_kr_pretty",
+        importpath = "github.com/kr/pretty",
+        sum = "h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=",
+        version = "v0.1.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_kr_pty",
+        importpath = "github.com/kr/pty",
+        sum = "h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=",
+        version = "v1.1.8",
+    )
+    maybe_override_go_dep(
+        name = "com_github_kr_text",
+        importpath = "github.com/kr/text",
+        sum = "h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=",
+        version = "v0.1.0",
     )
     maybe_override_go_dep(
         name = "com_github_lib_pq",
@@ -159,6 +297,24 @@
         version = "v1.10.2",
     )
     maybe_override_go_dep(
+        name = "com_github_masterminds_semver_v3",
+        importpath = "github.com/Masterminds/semver/v3",
+        sum = "h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=",
+        version = "v3.1.1",
+    )
+    maybe_override_go_dep(
+        name = "com_github_mattn_go_colorable",
+        importpath = "github.com/mattn/go-colorable",
+        sum = "h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=",
+        version = "v0.1.6",
+    )
+    maybe_override_go_dep(
+        name = "com_github_mattn_go_isatty",
+        importpath = "github.com/mattn/go-isatty",
+        sum = "h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=",
+        version = "v0.0.12",
+    )
+    maybe_override_go_dep(
         name = "com_github_phst_runfiles",
         importpath = "github.com/phst/runfiles",
         sum = "h1:N5aMcF9W9AjW4ed+PJhA7+FjdgPa9gJ+St3mNu2tq1Q=",
@@ -189,16 +345,46 @@
         version = "v1.2.0",
     )
     maybe_override_go_dep(
+        name = "com_github_rogpeppe_go_internal",
+        importpath = "github.com/rogpeppe/go-internal",
+        sum = "h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk=",
+        version = "v1.3.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_rs_xid",
+        importpath = "github.com/rs/xid",
+        sum = "h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc=",
+        version = "v1.2.1",
+    )
+    maybe_override_go_dep(
+        name = "com_github_rs_zerolog",
+        importpath = "github.com/rs/zerolog",
+        sum = "h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY=",
+        version = "v1.15.0",
+    )
+    maybe_override_go_dep(
+        name = "com_github_satori_go_uuid",
+        importpath = "github.com/satori/go.uuid",
+        sum = "h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=",
+        version = "v1.2.0",
+    )
+    maybe_override_go_dep(
         name = "com_github_shopspring_decimal",
         importpath = "github.com/shopspring/decimal",
         sum = "h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=",
         version = "v1.2.0",
     )
     maybe_override_go_dep(
+        name = "com_github_sirupsen_logrus",
+        importpath = "github.com/sirupsen/logrus",
+        sum = "h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=",
+        version = "v1.4.2",
+    )
+    maybe_override_go_dep(
         name = "com_github_stretchr_objx",
         importpath = "github.com/stretchr/objx",
-        sum = "h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=",
-        version = "v0.1.0",
+        sum = "h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=",
+        version = "v0.2.0",
     )
     maybe_override_go_dep(
         name = "com_github_stretchr_testify",
@@ -207,6 +393,12 @@
         version = "v1.7.0",
     )
     maybe_override_go_dep(
+        name = "com_github_zenazn_goji",
+        importpath = "github.com/zenazn/goji",
+        sum = "h1:RSQQAbXGArQ0dIDEq+PI6WqN6if+5KHu6x2Cx/GXLTQ=",
+        version = "v0.9.0",
+    )
+    maybe_override_go_dep(
         name = "com_google_cloud_go",
         importpath = "cloud.google.com/go",
         sum = "h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg=",
@@ -215,8 +407,20 @@
     maybe_override_go_dep(
         name = "in_gopkg_check_v1",
         importpath = "gopkg.in/check.v1",
-        sum = "h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=",
-        version = "v0.0.0-20161208181325-20d25e280405",
+        sum = "h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=",
+        version = "v1.0.0-20180628173108-788fd7840127",
+    )
+    maybe_override_go_dep(
+        name = "in_gopkg_errgo_v2",
+        importpath = "gopkg.in/errgo.v2",
+        sum = "h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=",
+        version = "v2.1.0",
+    )
+    maybe_override_go_dep(
+        name = "in_gopkg_inconshreveable_log15_v2",
+        importpath = "gopkg.in/inconshreveable/log15.v2",
+        sum = "h1:RlWgLqCMMIYYEVcAR5MDsuHlVkaIPDAF+5Dehzg8L5A=",
+        version = "v2.0.0-20180818164646-67afb5ed74ec",
     )
     maybe_override_go_dep(
         name = "in_gopkg_yaml_v2",
@@ -231,6 +435,18 @@
         version = "v3.0.0-20200313102051-9f266ea9e77c",
     )
     maybe_override_go_dep(
+        name = "io_gorm_driver_postgres",
+        importpath = "gorm.io/driver/postgres",
+        sum = "h1:FKF6sIMDHDEvvMF/XJvbnCl0nu6KSKUaPXevJ4r+VYQ=",
+        version = "v1.3.7",
+    )
+    maybe_override_go_dep(
+        name = "io_gorm_gorm",
+        importpath = "gorm.io/gorm",
+        sum = "h1:TnlF26wScKSvknUC/Rn8t0NLLM22fypYBlvj1+aH6dM=",
+        version = "v1.23.5",
+    )
+    maybe_override_go_dep(
         name = "io_opentelemetry_go_proto_otlp",
         importpath = "go.opentelemetry.io/proto/otlp",
         sum = "h1:rwOQPCuKAKmwGKq2aVNnYIibI6wnV7EvzgfTCzcdGg8=",
@@ -263,8 +479,8 @@
     maybe_override_go_dep(
         name = "org_golang_x_crypto",
         importpath = "golang.org/x/crypto",
-        sum = "h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=",
-        version = "v0.0.0-20210711020723-a769d52b0f97",
+        sum = "h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=",
+        version = "v0.0.0-20210921155107-089bfa567519",
     )
     maybe_override_go_dep(
         name = "org_golang_x_exp",
@@ -275,8 +491,14 @@
     maybe_override_go_dep(
         name = "org_golang_x_lint",
         importpath = "golang.org/x/lint",
-        sum = "h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0=",
-        version = "v0.0.0-20190313153728-d0100b6bd8b3",
+        sum = "h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=",
+        version = "v0.0.0-20190930215403-16217165b5de",
+    )
+    maybe_override_go_dep(
+        name = "org_golang_x_mod",
+        importpath = "golang.org/x/mod",
+        sum = "h1:WG0RUwxtNT4qqaXX3DPA8zHFNm/D9xaBpxzHt1WcA/E=",
+        version = "v0.1.1-0.20191105210325-c90efee705ee",
     )
     maybe_override_go_dep(
         name = "org_golang_x_net",
@@ -311,14 +533,14 @@
     maybe_override_go_dep(
         name = "org_golang_x_text",
         importpath = "golang.org/x/text",
-        sum = "h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=",
-        version = "v0.3.6",
+        sum = "h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=",
+        version = "v0.3.7",
     )
     maybe_override_go_dep(
         name = "org_golang_x_tools",
         importpath = "golang.org/x/tools",
-        sum = "h1:5Beo0mZN8dRzgrMMkDp0jc8YXQKx9DiJ2k1dkvGsn5A=",
-        version = "v0.0.0-20190524140312-2c0ae7006135",
+        sum = "h1:DnSr2mCsxyCE6ZgIkmcWUQY2R5cH/6wL7eIxEmQOMSE=",
+        version = "v0.0.0-20200103221440-774c71fcf114",
     )
     maybe_override_go_dep(
         name = "org_golang_x_xerrors",
@@ -326,3 +548,27 @@
         sum = "h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=",
         version = "v0.0.0-20200804184101-5ec99f83aff1",
     )
+    maybe_override_go_dep(
+        name = "org_uber_go_atomic",
+        importpath = "go.uber.org/atomic",
+        sum = "h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=",
+        version = "v1.6.0",
+    )
+    maybe_override_go_dep(
+        name = "org_uber_go_multierr",
+        importpath = "go.uber.org/multierr",
+        sum = "h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A=",
+        version = "v1.5.0",
+    )
+    maybe_override_go_dep(
+        name = "org_uber_go_tools",
+        importpath = "go.uber.org/tools",
+        sum = "h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4=",
+        version = "v0.0.0-20190618225709-2cfd321de3ee",
+    )
+    maybe_override_go_dep(
+        name = "org_uber_go_zap",
+        importpath = "go.uber.org/zap",
+        sum = "h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU=",
+        version = "v1.13.0",
+    )
diff --git a/scouting/db/BUILD b/scouting/db/BUILD
index 7fbd2e2..154cab6 100644
--- a/scouting/db/BUILD
+++ b/scouting/db/BUILD
@@ -6,7 +6,12 @@
     importpath = "github.com/frc971/971-Robot-Code/scouting/db",
     target_compatible_with = ["@platforms//cpu:x86_64"],
     visibility = ["//visibility:public"],
-    deps = ["@com_github_jackc_pgx//stdlib"],
+    deps = [
+        "@io_gorm_driver_postgres//:postgres",
+        "@io_gorm_gorm//:gorm",
+        "@io_gorm_gorm//clause",
+        "@io_gorm_gorm//logger",
+    ],
 )
 
 go_test(
@@ -18,4 +23,5 @@
     ],
     embed = [":db"],
     target_compatible_with = ["@platforms//cpu:x86_64"],
+    deps = ["@com_github_davecgh_go_spew//spew"],
 )
diff --git a/scouting/db/db.go b/scouting/db/db.go
index 3d514e3..55f2310 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -1,33 +1,50 @@
 package db
 
 import (
-	"database/sql"
 	"errors"
 	"fmt"
 
-	_ "github.com/jackc/pgx/stdlib"
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+	"gorm.io/gorm/logger"
 )
 
 type Database struct {
-	*sql.DB
+	*gorm.DB
 }
 
 type Match struct {
-	MatchNumber, SetNumber int32
-	CompLevel              string
+	// TODO(phil): Rework this be be one team per row.
+	// Makes queries much simpler.
+	MatchNumber            int32  `gorm:"primaryKey"`
+	SetNumber              int32  `gorm:"primaryKey"`
+	CompLevel              string `gorm:"primaryKey"`
 	R1, R2, R3, B1, B2, B3 int32
 }
 
 type Shift struct {
-	MatchNumber                                                      int32
+	MatchNumber                                                      int32 `gorm:"primaryKey"`
 	R1scouter, R2scouter, R3scouter, B1scouter, B2scouter, B3scouter string
 }
 
 type Stats struct {
-	TeamNumber, MatchNumber, SetNumber int32
-	CompLevel                          string
-	StartingQuadrant                   int32
-	AutoBallPickedUp                   [5]bool
+	TeamNumber       int32  `gorm:"primaryKey"`
+	MatchNumber      int32  `gorm:"primaryKey"`
+	SetNumber        int32  `gorm:"primaryKey"`
+	CompLevel        string `gorm:"primaryKey"`
+	StartingQuadrant int32
+	// This field is for the balls picked up during auto. Use this field
+	// when using this library. Ignore the AutoBallPickedUpX fields below.
+	AutoBallPickedUp [5]bool `gorm:"-:all"`
+	// These fields are internal implementation details. Do not use these.
+	// TODO(phil): Figure out how to use the JSON gorm serializer instead
+	// of manually serializing/deserializing these.
+	AutoBallPickedUp1 bool
+	AutoBallPickedUp2 bool
+	AutoBallPickedUp3 bool
+	AutoBallPickedUp4 bool
+	AutoBallPickedUp5 bool
 	// TODO(phil): Re-order auto and teleop fields so auto comes first.
 	ShotsMissed, UpperGoalShots, LowerGoalShots   int32
 	ShotsMissedAuto, UpperGoalAuto, LowerGoalAuto int32
@@ -50,233 +67,85 @@
 }
 
 type NotesData struct {
-	TeamNumber int32
-	Notes      []string
+	ID           uint `gorm:"primaryKey"`
+	TeamNumber   int32
+	Notes        string
+	GoodDriving  bool
+	BadDriving   bool
+	SketchyClimb bool
+	SolidClimb   bool
+	GoodDefense  bool
+	BadDefense   bool
 }
 
 type Ranking struct {
-	TeamNumber         int
+	TeamNumber         int `gorm:"primaryKey"`
 	Losses, Wins, Ties int32
 	Rank, Dq           int32
 }
 
+type DriverRankingData struct {
+	// Each entry in the table is a single scout's ranking.
+	// Multiple scouts can submit a driver ranking for the same
+	// teams in the same match.
+	// The teams being ranked are stored in Rank1, Rank2, Rank3,
+	// Rank1 being the best driving and Rank3 being the worst driving.
+
+	ID          uint `gorm:"primaryKey"`
+	MatchNumber int32
+	Rank1       int32
+	Rank2       int32
+	Rank3       int32
+}
+
 // Opens a database at the specified port on localhost. We currently don't
 // support connecting to databases on other hosts.
 func NewDatabase(user string, password string, port int) (*Database, error) {
 	var err error
 	database := new(Database)
 
-	psqlInfo := fmt.Sprintf("postgres://%s:%s@localhost:%d/postgres", user, password, port)
-	database.DB, err = sql.Open("pgx", psqlInfo)
+	dsn := fmt.Sprintf("host=localhost user=%s password=%s dbname=postgres port=%d sslmode=disable", user, password, port)
+	database.DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
+		Logger: logger.Default.LogMode(logger.Silent),
+	})
 	if err != nil {
+		database.Delete()
 		return nil, errors.New(fmt.Sprint("Failed to connect to postgres: ", err))
 	}
 
-	statement, err := database.Prepare("CREATE TABLE IF NOT EXISTS matches (" +
-		"MatchNumber INTEGER, " +
-		"SetNumber INTEGER, " +
-		"CompLevel VARCHAR, " +
-		"R1 INTEGER, " +
-		"R2 INTEGER, " +
-		"R3 INTEGER, " +
-		"B1 INTEGER, " +
-		"B2 INTEGER, " +
-		"B3 INTEGER, " +
-		"PRIMARY KEY (MatchNumber, SetNumber, CompLevel))")
+	err = database.AutoMigrate(&Match{}, &Shift{}, &Stats{}, &NotesData{}, &Ranking{}, &DriverRankingData{})
 	if err != nil {
-		database.Close()
-		return nil, errors.New(fmt.Sprint("Failed to prepare matches table creation: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec()
-	if err != nil {
-		database.Close()
-		return nil, errors.New(fmt.Sprint("Failed to create matches table: ", err))
-	}
-
-	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS shift_schedule (" +
-		"id SERIAL PRIMARY KEY, " +
-		"MatchNumber INTEGER, " +
-		"R1Scouter VARCHAR, " +
-		"R2Scouter VARCHAR, " +
-		"R3Scouter VARCHAR, " +
-		"B1Scouter VARCHAR, " +
-		"B2Scouter VARCHAR, " +
-		"B3scouter VARCHAR)")
-	if err != nil {
-		database.Close()
-		return nil, errors.New(fmt.Sprint("Failed to prepare shift schedule table creation: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec()
-	if err != nil {
-		database.Close()
-		return nil, errors.New(fmt.Sprint("Failed to create shift schedule table: ", err))
-	}
-
-	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS team_match_stats (" +
-		"TeamNumber INTEGER, " +
-		"MatchNumber INTEGER, " +
-		"SetNumber INTEGER, " +
-		"CompLevel VARCHAR, " +
-		"StartingQuadrant INTEGER, " +
-		"AutoBall1PickedUp BOOLEAN, " +
-		"AutoBall2PickedUp BOOLEAN, " +
-		"AutoBall3PickedUp BOOLEAN, " +
-		"AutoBall4PickedUp BOOLEAN, " +
-		"AutoBall5PickedUp BOOLEAN, " +
-		"ShotsMissed INTEGER, " +
-		"UpperGoalShots INTEGER, " +
-		"LowerGoalShots INTEGER, " +
-		"ShotsMissedAuto INTEGER, " +
-		"UpperGoalAuto INTEGER, " +
-		"LowerGoalAuto INTEGER, " +
-		"PlayedDefense INTEGER, " +
-		"DefenseReceivedScore INTEGER, " +
-		"Climbing INTEGER, " +
-		"Comment VARCHAR, " +
-		"CollectedBy VARCHAR, " +
-		"PRIMARY KEY (TeamNumber, MatchNumber, SetNumber, CompLevel))")
-	if err != nil {
-		database.Close()
-		return nil, errors.New(fmt.Sprint("Failed to prepare stats table creation: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec()
-	if err != nil {
-		database.Close()
-		return nil, errors.New(fmt.Sprint("Failed to create team_match_stats table: ", err))
-	}
-
-	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS team_notes (" +
-		"id SERIAL PRIMARY KEY, " +
-		"TeamNumber INTEGER, " +
-		"Notes TEXT)")
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to prepare notes table creation: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec()
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to create notes table: ", err))
-	}
-
-	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS rankings (" +
-		"id SERIAL PRIMARY KEY, " +
-		"Losses INTEGER, " +
-		"Wins INTEGER, " +
-		"Ties INTEGER, " +
-		"Rank INTEGER, " +
-		"Dq INTEGER, " +
-		"TeamNumber INTEGER)")
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to prepare rankings table creation: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec()
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to create rankings table: ", err))
+		database.Delete()
+		return nil, errors.New(fmt.Sprint("Failed to create/migrate tables: ", err))
 	}
 
 	return database, nil
 }
 
 func (database *Database) Delete() error {
-	statement, err := database.Prepare("DROP TABLE IF EXISTS matches")
+	sql, err := database.DB.DB()
 	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare dropping matches table: ", err))
+		return err
 	}
-	_, err = statement.Exec()
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to drop matches table: ", err))
-	}
-
-	statement, err = database.Prepare("DROP TABLE IF EXISTS shift_schedule")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare dropping shifts table: ", err))
-	}
-	_, err = statement.Exec()
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to drop shifts table: ", err))
-	}
-
-	statement, err = database.Prepare("DROP TABLE IF EXISTS team_match_stats")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare dropping stats table: ", err))
-	}
-	_, err = statement.Exec()
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to drop stats table: ", err))
-	}
-
-	statement, err = database.Prepare("DROP TABLE IF EXISTS team_notes")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare dropping notes table: ", err))
-	}
-	_, err = statement.Exec()
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to drop notes table: ", err))
-	}
-	return nil
-
-	statement, err = database.Prepare("DROP TABLE IF EXISTS rankings")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare dropping rankings table: ", err))
-	}
-	_, err = statement.Exec()
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to drop rankings table: ", err))
-	}
-	return nil
+	return sql.Close()
 }
 
-// This function will also populate the Stats table with six empty rows every time a match is added
-func (database *Database) AddToMatch(m Match) error {
-	statement, err := database.Prepare("INSERT INTO matches(" +
-		"MatchNumber, SetNumber, CompLevel, " +
-		"R1, R2, R3, B1, B2, B3) " +
-		"VALUES (" +
-		"$1, $2, $3, " +
-		"$4, $5, $6, $7, $8, $9) " +
-		"ON CONFLICT (MatchNumber, SetNumber, CompLevel) DO UPDATE SET " +
-		"R1 = EXCLUDED.R1, R2 = EXCLUDED.R2, R3 = EXCLUDED.R3, " +
-		"B1 = EXCLUDED.B1, B2 = EXCLUDED.B2, B3 = EXCLUDED.B3")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare insertion into match database: ", err))
-	}
-	defer statement.Close()
+func (database *Database) SetDebugLogLevel() {
+	database.DB.Logger = database.DB.Logger.LogMode(logger.Info)
+}
 
-	_, err = statement.Exec(m.MatchNumber, m.SetNumber, m.CompLevel,
-		m.R1, m.R2, m.R3, m.B1, m.B2, m.B3)
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to insert into match database: ", err))
-	}
-	return nil
+func (database *Database) AddToMatch(m Match) error {
+	result := database.Clauses(clause.OnConflict{
+		UpdateAll: true,
+	}).Create(&m)
+	return result.Error
 }
 
 func (database *Database) AddToShift(sh Shift) error {
-	statement, err := database.Prepare("INSERT INTO shift_schedule(" +
-		"MatchNumber, " +
-		"R1scouter, R2scouter, R3scouter, B1scouter, B2scouter, B3scouter) " +
-		"VALUES (" +
-		"$1, " +
-		"$2, $3, $4, $5, $6, $7)")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare insertion into shift database: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec(sh.MatchNumber,
-		sh.R1scouter, sh.R2scouter, sh.R3scouter, sh.B1scouter, sh.B2scouter, sh.B3scouter)
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to insert into shift database: ", err))
-	}
-	return nil
+	result := database.Clauses(clause.OnConflict{
+		UpdateAll: true,
+	}).Create(&sh)
+	return result.Error
 }
 
 func (database *Database) AddToStats(s Stats) error {
@@ -297,304 +166,139 @@
 			" in match ", s.MatchNumber, " in the schedule."))
 	}
 
-	statement, err := database.Prepare("INSERT INTO team_match_stats(" +
-		"TeamNumber, MatchNumber, SetNumber, CompLevel, " +
-		"StartingQuadrant, " +
-		"AutoBall1PickedUp, AutoBall2PickedUp, AutoBall3PickedUp, " +
-		"AutoBall4PickedUp, AutoBall5PickedUp, " +
-		"ShotsMissed, UpperGoalShots, LowerGoalShots, " +
-		"ShotsMissedAuto, UpperGoalAuto, LowerGoalAuto, " +
-		"PlayedDefense, DefenseReceivedScore, Climbing, " +
-		"Comment, CollectedBy) " +
-		"VALUES (" +
-		"$1, $2, $3, $4, " +
-		"$5, " +
-		"$6, $7, $8, " +
-		"$9, $10, " +
-		"$11, $12, $13, " +
-		"$14, $15, $16, " +
-		"$17, $18, $19, " +
-		"$20, $21)")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare stats update statement: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec(
-		s.TeamNumber, s.MatchNumber, s.SetNumber, s.CompLevel,
-		s.StartingQuadrant,
-		s.AutoBallPickedUp[0], s.AutoBallPickedUp[1], s.AutoBallPickedUp[2],
-		s.AutoBallPickedUp[3], s.AutoBallPickedUp[4],
-		s.ShotsMissed, s.UpperGoalShots, s.LowerGoalShots,
-		s.ShotsMissedAuto, s.UpperGoalAuto, s.LowerGoalAuto,
-		s.PlayedDefense, s.DefenseReceivedScore, s.Climbing,
-		s.Comment, s.CollectedBy)
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to update stats database: ", err))
-	}
-
-	return nil
+	// Unpack the auto balls array.
+	s.AutoBallPickedUp1 = s.AutoBallPickedUp[0]
+	s.AutoBallPickedUp2 = s.AutoBallPickedUp[1]
+	s.AutoBallPickedUp3 = s.AutoBallPickedUp[2]
+	s.AutoBallPickedUp4 = s.AutoBallPickedUp[3]
+	s.AutoBallPickedUp5 = s.AutoBallPickedUp[4]
+	result := database.Create(&s)
+	return result.Error
 }
 
 func (database *Database) AddOrUpdateRankings(r Ranking) error {
-	statement, err := database.Prepare("UPDATE rankings SET " +
-		"Losses = $1, Wins = $2, Ties = $3, " +
-		"Rank = $4, Dq = $5, TeamNumber = $6 " +
-		"WHERE TeamNumber = $6")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare rankings database update: ", err))
-	}
-	defer statement.Close()
-
-	result, err := statement.Exec(r.Losses, r.Wins, r.Ties,
-		r.Rank, r.Dq, r.TeamNumber)
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to update rankings database: ", err))
-	}
-
-	numRowsAffected, err := result.RowsAffected()
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to query rows affected: ", err))
-	}
-	if numRowsAffected == 0 {
-		statement, err := database.Prepare("INSERT INTO rankings(" +
-			"Losses, Wins, Ties, " +
-			"Rank, Dq, TeamNumber) " +
-			"VALUES (" +
-			"$1, $2, $3, " +
-			"$4, $5, $6)")
-		if err != nil {
-			return errors.New(fmt.Sprint("Failed to prepare insertion into rankings database: ", err))
-		}
-		defer statement.Close()
-
-		_, err = statement.Exec(r.Losses, r.Wins, r.Ties,
-			r.Rank, r.Dq, r.TeamNumber)
-		if err != nil {
-			return errors.New(fmt.Sprint("Failed to insert into rankings database: ", err))
-		}
-	}
-
-	return nil
+	result := database.Clauses(clause.OnConflict{
+		UpdateAll: true,
+	}).Create(&r)
+	return result.Error
 }
 
 func (database *Database) ReturnMatches() ([]Match, error) {
-	rows, err := database.Query("SELECT * FROM matches")
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to select from matches: ", err))
-	}
-	defer rows.Close()
-
-	matches := make([]Match, 0)
-	for rows.Next() {
-		var match Match
-		err := rows.Scan(&match.MatchNumber, &match.SetNumber, &match.CompLevel,
-			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
-		}
-		matches = append(matches, match)
-	}
-	return matches, nil
+	var matches []Match
+	result := database.Find(&matches)
+	return matches, result.Error
 }
 
 func (database *Database) ReturnAllShifts() ([]Shift, error) {
-	rows, err := database.Query("SELECT * FROM shift_schedule")
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to select from shift: ", err))
-	}
-	defer rows.Close()
+	var shifts []Shift
+	result := database.Find(&shifts)
+	return shifts, result.Error
+}
 
-	shifts := make([]Shift, 0)
-	for rows.Next() {
-		var shift Shift
-		var id int
-		err := rows.Scan(&id, &shift.MatchNumber,
-			&shift.R1scouter, &shift.R2scouter, &shift.R3scouter, &shift.B1scouter, &shift.B2scouter, &shift.B3scouter)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from shift: ", err))
-		}
-		shifts = append(shifts, shift)
+// Packs the stats. This really just consists of taking the individual auto
+// ball booleans and turning them into an array. The individual booleans are
+// cleared so that they don't affect struct comparisons.
+func packStats(stats *Stats) {
+	stats.AutoBallPickedUp = [5]bool{
+		stats.AutoBallPickedUp1,
+		stats.AutoBallPickedUp2,
+		stats.AutoBallPickedUp3,
+		stats.AutoBallPickedUp4,
+		stats.AutoBallPickedUp5,
 	}
-	return shifts, nil
+	stats.AutoBallPickedUp1 = false
+	stats.AutoBallPickedUp2 = false
+	stats.AutoBallPickedUp3 = false
+	stats.AutoBallPickedUp4 = false
+	stats.AutoBallPickedUp5 = false
 }
 
 func (database *Database) ReturnStats() ([]Stats, error) {
-	rows, err := database.Query("SELECT * FROM team_match_stats")
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to SELECT * FROM team_match_stats: ", err))
+	var stats []Stats
+	result := database.Find(&stats)
+	// Pack the auto balls array.
+	for i := range stats {
+		packStats(&stats[i])
 	}
-	defer rows.Close()
-
-	teams := make([]Stats, 0)
-	for rows.Next() {
-		var team Stats
-		err = rows.Scan(
-			&team.TeamNumber, &team.MatchNumber, &team.SetNumber, &team.CompLevel,
-			&team.StartingQuadrant,
-			&team.AutoBallPickedUp[0], &team.AutoBallPickedUp[1], &team.AutoBallPickedUp[2],
-			&team.AutoBallPickedUp[3], &team.AutoBallPickedUp[4],
-			&team.ShotsMissed, &team.UpperGoalShots, &team.LowerGoalShots,
-			&team.ShotsMissedAuto, &team.UpperGoalAuto, &team.LowerGoalAuto,
-			&team.PlayedDefense, &team.DefenseReceivedScore, &team.Climbing,
-			&team.Comment, &team.CollectedBy)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from stats: ", err))
-		}
-		teams = append(teams, team)
-	}
-	return teams, nil
+	return stats, result.Error
 }
 
 func (database *Database) ReturnRankings() ([]Ranking, error) {
-	rows, err := database.Query("SELECT * FROM rankings")
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to SELECT * FROM rankings: ", err))
-	}
-	defer rows.Close()
-
-	all_rankings := make([]Ranking, 0)
-	for rows.Next() {
-		var ranking Ranking
-		var id int
-		err = rows.Scan(&id,
-			&ranking.Losses, &ranking.Wins, &ranking.Ties,
-			&ranking.Rank, &ranking.Dq, &ranking.TeamNumber)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from rankings: ", err))
-		}
-		all_rankings = append(all_rankings, ranking)
-	}
-	return all_rankings, nil
+	var rankins []Ranking
+	result := database.Find(&rankins)
+	return rankins, result.Error
 }
 
 func (database *Database) QueryMatches(teamNumber_ int32) ([]Match, error) {
-	rows, err := database.Query("SELECT * FROM matches WHERE "+
-		"R1 = $1 OR R2 = $2 OR R3 = $3 OR B1 = $4 OR B2 = $5 OR B3 = $6",
-		teamNumber_, teamNumber_, teamNumber_, teamNumber_, teamNumber_, teamNumber_)
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to select from matches for team: ", err))
-	}
-	defer rows.Close()
-
 	var matches []Match
-	for rows.Next() {
-		var match Match
-		err = rows.Scan(&match.MatchNumber, &match.SetNumber, &match.CompLevel,
-			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
-		}
-		matches = append(matches, match)
-	}
-	return matches, nil
+	result := database.
+		Where("r1 = $1 OR r2 = $1 OR r3 = $1 OR b1 = $1 OR b2 = $1 OR b3 = $1", teamNumber_).
+		Find(&matches)
+	return matches, result.Error
 }
 
 func (database *Database) QueryAllShifts(matchNumber_ int) ([]Shift, error) {
-	rows, err := database.Query("SELECT * FROM shift_schedule WHERE MatchNumber = $1", matchNumber_)
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to select from shift for team: ", err))
-	}
-	defer rows.Close()
-
 	var shifts []Shift
-	for rows.Next() {
-		var shift Shift
-		var id int
-		err = rows.Scan(&id, &shift.MatchNumber,
-			&shift.R1scouter, &shift.R2scouter, &shift.R3scouter, &shift.B1scouter, &shift.B2scouter, &shift.B3scouter)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
-		}
-		shifts = append(shifts, shift)
-	}
-	return shifts, nil
+	result := database.Where("match_number = ?", matchNumber_).Find(&shifts)
+	return shifts, result.Error
 }
 
 func (database *Database) QueryStats(teamNumber_ int) ([]Stats, error) {
-	rows, err := database.Query("SELECT * FROM team_match_stats WHERE TeamNumber = $1", teamNumber_)
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to select from stats: ", err))
+	var stats []Stats
+	result := database.Where("team_number = ?", teamNumber_).Find(&stats)
+	// Pack the auto balls array.
+	for i := range stats {
+		packStats(&stats[i])
 	}
-	defer rows.Close()
-
-	var teams []Stats
-	for rows.Next() {
-		var team Stats
-		err = rows.Scan(
-			&team.TeamNumber, &team.MatchNumber, &team.SetNumber, &team.CompLevel,
-			&team.StartingQuadrant,
-			&team.AutoBallPickedUp[0], &team.AutoBallPickedUp[1], &team.AutoBallPickedUp[2],
-			&team.AutoBallPickedUp[3], &team.AutoBallPickedUp[4],
-			&team.ShotsMissed, &team.UpperGoalShots, &team.LowerGoalShots,
-			&team.ShotsMissedAuto, &team.UpperGoalAuto, &team.LowerGoalAuto,
-			&team.PlayedDefense, &team.DefenseReceivedScore, &team.Climbing,
-			&team.Comment, &team.CollectedBy)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from stats: ", err))
-		}
-		teams = append(teams, team)
-	}
-	return teams, nil
+	return stats, result.Error
 }
 
-func (database *Database) QueryNotes(TeamNumber int32) (NotesData, error) {
-	rows, err := database.Query("SELECT * FROM team_notes WHERE TeamNumber = $1", TeamNumber)
-	if err != nil {
-		return NotesData{}, errors.New(fmt.Sprint("Failed to select from notes: ", err))
+func (database *Database) QueryNotes(TeamNumber int32) ([]string, error) {
+	var rawNotes []NotesData
+	result := database.Where("team_number = ?", TeamNumber).Find(&rawNotes)
+	if result.Error != nil {
+		return nil, result.Error
 	}
-	defer rows.Close()
 
-	var notes []string
-	for rows.Next() {
-		var id int32
-		var data string
-		err = rows.Scan(&id, &TeamNumber, &data)
-		if err != nil {
-			return NotesData{}, errors.New(fmt.Sprint("Failed to scan from notes: ", err))
-		}
-		notes = append(notes, data)
+	notes := make([]string, len(rawNotes))
+	for i := range rawNotes {
+		notes[i] = rawNotes[i].Notes
 	}
-	return NotesData{TeamNumber, notes}, nil
+	return notes, nil
 }
 
 func (database *Database) QueryRankings(TeamNumber int) ([]Ranking, error) {
-	rows, err := database.Query("SELECT * FROM rankings WHERE TeamNumber = $1", TeamNumber)
-	if err != nil {
-		return nil, errors.New(fmt.Sprint("Failed to select from rankings: ", err))
-	}
-	defer rows.Close()
-
-	all_rankings := make([]Ranking, 0)
-	for rows.Next() {
-		var ranking Ranking
-		var id int
-		err = rows.Scan(&id,
-			&ranking.Losses, &ranking.Wins, &ranking.Ties,
-			&ranking.Rank, &ranking.Dq, &ranking.TeamNumber)
-		if err != nil {
-			return nil, errors.New(fmt.Sprint("Failed to scan from rankings: ", err))
-		}
-		all_rankings = append(all_rankings, ranking)
-	}
-	return all_rankings, nil
+	var rankins []Ranking
+	result := database.Where("team_number = ?", TeamNumber).Find(&rankins)
+	return rankins, result.Error
 }
 
 func (database *Database) AddNotes(data NotesData) error {
-	if len(data.Notes) > 1 {
-		return errors.New("Can only insert one row of notes at a time")
-	}
-	statement, err := database.Prepare("INSERT INTO " +
-		"team_notes(TeamNumber, Notes)" +
-		"VALUES ($1, $2)")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare insertion into notes table: ", err))
-	}
-	defer statement.Close()
+	result := database.Create(&NotesData{
+		TeamNumber:   data.TeamNumber,
+		Notes:        data.Notes,
+		GoodDriving:  data.GoodDriving,
+		BadDriving:   data.BadDriving,
+		SketchyClimb: data.SketchyClimb,
+		SolidClimb:   data.SolidClimb,
+		GoodDefense:  data.GoodDefense,
+		BadDefense:   data.BadDefense,
+	})
+	return result.Error
+}
 
-	_, err = statement.Exec(data.TeamNumber, data.Notes[0])
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to insert into Notes database: ", err))
-	}
-	return nil
+func (database *Database) AddDriverRanking(data DriverRankingData) error {
+	result := database.Create(&DriverRankingData{
+		MatchNumber: data.MatchNumber,
+		Rank1:       data.Rank1,
+		Rank2:       data.Rank2,
+		Rank3:       data.Rank3,
+	})
+	return result.Error
+}
+
+func (database *Database) QueryDriverRanking(MatchNumber int) ([]DriverRankingData, error) {
+	var data []DriverRankingData
+	result := database.Where("match_number = ?", MatchNumber).Find(&data)
+	return data, result.Error
 }
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 438e52e..460b177 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -9,6 +9,8 @@
 	"strings"
 	"testing"
 	"time"
+
+	"github.com/davecgh/go-spew/spew"
 )
 
 // Shortcut for error checking. If the specified error is non-nil, print the
@@ -26,7 +28,6 @@
 
 func (fixture dbFixture) TearDown() {
 	fixture.db.Delete()
-	fixture.db.Close()
 	log.Println("Shutting down testdb")
 	fixture.server.Process.Signal(os.Interrupt)
 	fixture.server.Process.Wait()
@@ -55,9 +56,17 @@
 	}
 	log.Println("Connected to postgres.")
 
+	fixture.db.SetDebugLogLevel()
+
 	return fixture
 }
 
+func checkDeepEqual(t *testing.T, expected interface{}, actual interface{}) {
+	if !reflect.DeepEqual(expected, actual) {
+		t.Fatalf(spew.Sprintf("Got %#v,\nbut expected %#v.", actual, expected))
+	}
+}
+
 func TestAddToMatchDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
@@ -77,9 +86,7 @@
 	got, err := fixture.db.ReturnMatches()
 	check(t, err, "Failed ReturnMatches()")
 
-	if !reflect.DeepEqual(correct, got) {
-		t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
-	}
+	checkDeepEqual(t, correct, got)
 }
 
 func TestAddOrUpdateRankingsDB(t *testing.T) {
@@ -198,6 +205,7 @@
 
 	stats := Stats{
 		TeamNumber: 1236, MatchNumber: 7,
+		SetNumber: 1, CompLevel: "qual",
 		StartingQuadrant: 2,
 		AutoBallPickedUp: [5]bool{false, false, false, true, false},
 		ShotsMissed:      9, UpperGoalShots: 5, LowerGoalShots: 4,
@@ -698,25 +706,20 @@
 	got, err := fixture.db.QueryRankings(125)
 	check(t, err, "Failed QueryRankings()")
 
-	if !reflect.DeepEqual(correct, got) {
-		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
-	}
+	checkDeepEqual(t, correct, got)
 }
 
 func TestNotes(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
 
-	expected := NotesData{
-		TeamNumber: 1234,
-		Notes:      []string{"Note 1", "Note 3"},
-	}
+	expected := []string{"Note 1", "Note 3"}
 
-	err := fixture.db.AddNotes(NotesData{1234, []string{"Note 1"}})
+	err := fixture.db.AddNotes(NotesData{TeamNumber: 1234, Notes: "Note 1", GoodDriving: true, BadDriving: false, SketchyClimb: false, SolidClimb: true, GoodDefense: false, BadDefense: true})
 	check(t, err, "Failed to add Note")
-	err = fixture.db.AddNotes(NotesData{1235, []string{"Note 2"}})
+	err = fixture.db.AddNotes(NotesData{TeamNumber: 1235, Notes: "Note 2", GoodDriving: false, BadDriving: true, SketchyClimb: false, SolidClimb: true, GoodDefense: false, BadDefense: false})
 	check(t, err, "Failed to add Note")
-	err = fixture.db.AddNotes(NotesData{1234, []string{"Note 3"}})
+	err = fixture.db.AddNotes(NotesData{TeamNumber: 1234, Notes: "Note 3", GoodDriving: true, BadDriving: false, SketchyClimb: false, SolidClimb: true, GoodDefense: true, BadDefense: false})
 	check(t, err, "Failed to add Note")
 
 	actual, err := fixture.db.QueryNotes(1234)
@@ -726,3 +729,33 @@
 		t.Errorf("Got %#v,\nbut expected %#v.", actual, expected)
 	}
 }
+
+func TestDriverRanking(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	expected := []DriverRankingData{
+		{ID: 1, MatchNumber: 12, Rank1: 1234, Rank2: 1235, Rank3: 1236},
+		{ID: 2, MatchNumber: 12, Rank1: 1236, Rank2: 1235, Rank3: 1234},
+	}
+
+	err := fixture.db.AddDriverRanking(
+		DriverRankingData{MatchNumber: 12, Rank1: 1234, Rank2: 1235, Rank3: 1236},
+	)
+	check(t, err, "Failed to add Driver Ranking")
+	err = fixture.db.AddDriverRanking(
+		DriverRankingData{MatchNumber: 12, Rank1: 1236, Rank2: 1235, Rank3: 1234},
+	)
+	check(t, err, "Failed to add Driver Ranking")
+	err = fixture.db.AddDriverRanking(
+		DriverRankingData{MatchNumber: 13, Rank1: 1235, Rank2: 1234, Rank3: 1236},
+	)
+	check(t, err, "Failed to add Driver Ranking")
+
+	actual, err := fixture.db.QueryDriverRanking(12)
+	check(t, err, "Failed to get Driver Ranking")
+
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Got %#v,\nbut expected %#v.", actual, expected)
+	}
+}
diff --git a/scouting/scouting_test.ts b/scouting/scouting_test.ts
index a34d98c..8623fa1 100644
--- a/scouting/scouting_test.ts
+++ b/scouting/scouting_test.ts
@@ -241,7 +241,6 @@
 
     expect(await getHeadingText()).toEqual('Climb');
     await element(by.id('high')).click();
-    await setTextboxByIdTo('comment', 'A very useful comment here.');
     await element(by.buttonText('Next')).click();
 
     expect(await getHeadingText()).toEqual('Other');
@@ -249,6 +248,7 @@
     await adjustNthSliderBy(1, 1);
     await element(by.id('no_show')).click();
     await element(by.id('mechanically_broke')).click();
+    await setTextboxByIdTo('comment', 'A very useful comment here.');
     await element(by.buttonText('Next')).click();
 
     expect(await getHeadingText()).toEqual('Review and Submit');
@@ -273,7 +273,6 @@
 
     // Validate Climb.
     await expectReviewFieldToBe('Climb Level', 'High');
-    await expectReviewFieldToBe('Comments', 'A very useful comment here.');
 
     // Validate Other.
     await expectReviewFieldToBe('Defense Played On Rating', '3');
@@ -282,6 +281,7 @@
     await expectReviewFieldToBe('Never moved', 'false');
     await expectReviewFieldToBe('Battery died', 'false');
     await expectReviewFieldToBe('Broke (mechanically)', 'true');
+    await expectReviewFieldToBe('Comments', 'A very useful comment here.');
 
     await element(by.buttonText('Submit')).click();
     await browser.wait(
@@ -322,4 +322,108 @@
       await element(by.buttonText('Flip')).click();
     }
   });
+
+  it('should: submit note scouting for multiple teams', async () => {
+    // Navigate to Notes Page.
+    await loadPage();
+    await element(by.cssContainingText('.nav-link', 'Notes')).click();
+    expect(await element(by.id('page-title')).getText()).toEqual('Notes');
+
+    // Add first team.
+    await setTextboxByIdTo('team_number_notes', '1234');
+    await element(by.buttonText('Select')).click();
+
+    // Add note and select keyword for first team.
+    expect(await element(by.id('team-key-1')).getText()).toEqual('1234');
+    await element(by.id('text-input-1')).sendKeys('Good Driving');
+    await element(by.id('Good Driving_0')).click();
+
+    // Navigate to add team selection and add another team.
+    await element(by.id('add-team-button')).click();
+    await setTextboxByIdTo('team_number_notes', '1235');
+    await element(by.buttonText('Select')).click();
+
+    // Add note and select keyword for second team.
+    expect(await element(by.id('team-key-2')).getText()).toEqual('1235');
+    await element(by.id('text-input-2')).sendKeys('Bad Driving');
+    await element(by.id('Bad Driving_1')).click();
+
+    // Submit Notes.
+    await element(by.buttonText('Submit')).click();
+    expect(await element(by.id('team_number_label')).getText()).toEqual(
+      'Team Number'
+    );
+  });
+
+  it('should: switch note text boxes with keyboard shortcuts', async () => {
+    // Navigate to Notes Page.
+    await loadPage();
+    await element(by.cssContainingText('.nav-link', 'Notes')).click();
+    expect(await element(by.id('page-title')).getText()).toEqual('Notes');
+
+    // Add first team.
+    await setTextboxByIdTo('team_number_notes', '1234');
+    await element(by.buttonText('Select')).click();
+
+    // Add second team.
+    await element(by.id('add-team-button')).click();
+    await setTextboxByIdTo('team_number_notes', '1235');
+    await element(by.buttonText('Select')).click();
+
+    // Add third team.
+    await element(by.id('add-team-button')).click();
+    await setTextboxByIdTo('team_number_notes', '1236');
+    await element(by.buttonText('Select')).click();
+
+    for (let i = 1; i <= 3; i++) {
+      // Press Control + i
+      // Keyup Control for future actions.
+      browser
+        .actions()
+        .keyDown(protractor.Key.CONTROL)
+        .sendKeys(i.toString())
+        .keyUp(protractor.Key.CONTROL)
+        .perform();
+
+      // Expect text input to be focused.
+      expect(
+        await browser.driver.switchTo().activeElement().getAttribute('id')
+      ).toEqual('text-input-' + i);
+    }
+  });
+  it('should: submit driver ranking', async () => {
+    // Navigate to Driver Ranking Page.
+    await loadPage();
+    await element(by.cssContainingText('.nav-link', 'Driver Ranking')).click();
+    expect(await element(by.id('page-title')).getText()).toEqual(
+      'Driver Ranking'
+    );
+
+    // Input match and team numbers.
+    await setTextboxByIdTo('match_number_selection', '11');
+    await setTextboxByIdTo('team_input_0', '123');
+    await setTextboxByIdTo('team_input_1', '456');
+    await setTextboxByIdTo('team_input_2', '789');
+    await element(by.id('select_button')).click();
+
+    // Verify match and team key input.
+    expect(await element(by.id('match_number_heading')).getText()).toEqual(
+      'Match #11'
+    );
+    expect(await element(by.id('team_key_label_0')).getText()).toEqual('123');
+    expect(await element(by.id('team_key_label_1')).getText()).toEqual('456');
+    expect(await element(by.id('team_key_label_2')).getText()).toEqual('789');
+
+    // Rank teams.
+    await element(by.id('up_button_2')).click();
+    await element(by.id('down_button_0')).click();
+
+    // Verify ranking change.
+    expect(await element(by.id('team_key_label_0')).getText()).toEqual('789');
+    expect(await element(by.id('team_key_label_1')).getText()).toEqual('123');
+    expect(await element(by.id('team_key_label_2')).getText()).toEqual('456');
+
+    // Submit.
+    await element(by.id('submit_button')).click();
+  });
 });
diff --git a/scouting/webserver/main.go b/scouting/webserver/main.go
index 5d4ab01..d2fbdfe 100644
--- a/scouting/webserver/main.go
+++ b/scouting/webserver/main.go
@@ -117,7 +117,7 @@
 	if err != nil {
 		log.Fatal("Failed to connect to database: ", err)
 	}
-	defer database.Close()
+	defer database.Delete()
 
 	scrapeMatchList := func(year int32, eventCode string) ([]scraping.Match, error) {
 		if *blueAllianceConfigPtr == "" {
diff --git a/scouting/webserver/requests/BUILD b/scouting/webserver/requests/BUILD
index 87575a9..5c9ede4 100644
--- a/scouting/webserver/requests/BUILD
+++ b/scouting/webserver/requests/BUILD
@@ -24,6 +24,8 @@
         "//scouting/webserver/requests/messages:request_shift_schedule_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_go_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_response_go_fbs",
+        "//scouting/webserver/requests/messages:submit_driver_ranking_go_fbs",
+        "//scouting/webserver/requests/messages:submit_driver_ranking_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_shift_schedule_go_fbs",
@@ -56,6 +58,7 @@
         "//scouting/webserver/requests/messages:request_shift_schedule_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_go_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_response_go_fbs",
+        "//scouting/webserver/requests/messages:submit_driver_ranking_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_go_fbs",
         "//scouting/webserver/requests/messages:submit_shift_schedule_go_fbs",
         "//scouting/webserver/server",
diff --git a/scouting/webserver/requests/debug/BUILD b/scouting/webserver/requests/debug/BUILD
index 04c4ffa..f826831 100644
--- a/scouting/webserver/requests/debug/BUILD
+++ b/scouting/webserver/requests/debug/BUILD
@@ -15,6 +15,7 @@
         "//scouting/webserver/requests/messages:request_notes_for_team_response_go_fbs",
         "//scouting/webserver/requests/messages:request_shift_schedule_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_response_go_fbs",
+        "//scouting/webserver/requests/messages:submit_driver_ranking_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_notes_response_go_fbs",
         "//scouting/webserver/requests/messages:submit_shift_schedule_response_go_fbs",
         "@com_github_google_flatbuffers//go:go_default_library",
diff --git a/scouting/webserver/requests/debug/debug.go b/scouting/webserver/requests/debug/debug.go
index b3df518..fc0896c 100644
--- a/scouting/webserver/requests/debug/debug.go
+++ b/scouting/webserver/requests/debug/debug.go
@@ -17,6 +17,7 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_notes_for_team_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule_response"
 	flatbuffers "github.com/google/flatbuffers/go"
@@ -157,3 +158,9 @@
 		server+"/requests/submit/shift_schedule", requestBytes,
 		submit_shift_schedule_response.GetRootAsSubmitShiftScheduleResponse)
 }
+
+func SubmitDriverRanking(server string, requestBytes []byte) (*submit_driver_ranking_response.SubmitDriverRankingResponseT, error) {
+	return sendMessage[submit_driver_ranking_response.SubmitDriverRankingResponseT](
+		server+"/requests/submit/submit_driver_ranking", requestBytes,
+		submit_driver_ranking_response.GetRootAsSubmitDriverRankingResponse)
+}
diff --git a/scouting/webserver/requests/messages/BUILD b/scouting/webserver/requests/messages/BUILD
index b2d21a2..c14a857 100644
--- a/scouting/webserver/requests/messages/BUILD
+++ b/scouting/webserver/requests/messages/BUILD
@@ -21,6 +21,8 @@
     "request_shift_schedule_response",
     "submit_shift_schedule",
     "submit_shift_schedule_response",
+    "submit_driver_ranking",
+    "submit_driver_ranking_response",
 )
 
 filegroup(
diff --git a/scouting/webserver/requests/messages/submit_driver_ranking.fbs b/scouting/webserver/requests/messages/submit_driver_ranking.fbs
new file mode 100644
index 0000000..ac1e218
--- /dev/null
+++ b/scouting/webserver/requests/messages/submit_driver_ranking.fbs
@@ -0,0 +1,10 @@
+namespace scouting.webserver.requests;
+
+table SubmitDriverRanking {
+    matchNumber:int (id: 0);
+    rank1:int (id: 1);
+    rank2:int (id: 2);
+    rank3:int (id: 3);
+}
+
+root_type SubmitDriverRanking;
diff --git a/scouting/webserver/requests/messages/submit_driver_ranking_response.fbs b/scouting/webserver/requests/messages/submit_driver_ranking_response.fbs
new file mode 100644
index 0000000..78c6445
--- /dev/null
+++ b/scouting/webserver/requests/messages/submit_driver_ranking_response.fbs
@@ -0,0 +1,8 @@
+namespace scouting.webserver.requests;
+
+table SubmitDriverRankingResponse {
+    // empty response
+}
+
+root_type SubmitDriverRankingResponse;
+
diff --git a/scouting/webserver/requests/messages/submit_notes.fbs b/scouting/webserver/requests/messages/submit_notes.fbs
index cf111b3..1498e26 100644
--- a/scouting/webserver/requests/messages/submit_notes.fbs
+++ b/scouting/webserver/requests/messages/submit_notes.fbs
@@ -3,6 +3,12 @@
 table SubmitNotes {
     team:int (id: 0);
     notes:string (id: 1);
+    good_driving:bool (id: 2);
+    bad_driving:bool (id: 3);
+    sketchy_climb:bool (id: 4);
+    solid_climb:bool (id: 5);
+    good_defense:bool (id: 6);
+    bad_defense:bool (id: 7);
 }
 
 root_type SubmitNotes;
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
index 67a1722..12bc3da 100644
--- a/scouting/webserver/requests/requests.go
+++ b/scouting/webserver/requests/requests.go
@@ -27,6 +27,8 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule"
@@ -53,6 +55,8 @@
 type RequestShiftScheduleResponseT = request_shift_schedule_response.RequestShiftScheduleResponseT
 type SubmitShiftSchedule = submit_shift_schedule.SubmitShiftSchedule
 type SubmitShiftScheduleResponseT = submit_shift_schedule_response.SubmitShiftScheduleResponseT
+type SubmitDriverRanking = submit_driver_ranking.SubmitDriverRanking
+type SubmitDriverRankingResponseT = submit_driver_ranking_response.SubmitDriverRankingResponseT
 
 // The interface we expect the database abstraction to conform to.
 // We use an interface here because it makes unit testing easier.
@@ -66,8 +70,9 @@
 	QueryMatches(int32) ([]db.Match, error)
 	QueryAllShifts(int) ([]db.Shift, error)
 	QueryStats(int) ([]db.Stats, error)
-	QueryNotes(int32) (db.NotesData, error)
+	QueryNotes(int32) ([]string, error)
 	AddNotes(db.NotesData) error
+	AddDriverRanking(db.DriverRankingData) error
 }
 
 type ScrapeMatchList func(int32, string) ([]scraping.Match, error)
@@ -337,7 +342,33 @@
 func parseTeamKey(teamKey string) (int, error) {
 	// TBA prefixes teams with "frc". Not sure why. Get rid of that.
 	teamKey = strings.TrimPrefix(teamKey, "frc")
-	return strconv.Atoi(teamKey)
+	magnitude := 0
+	if strings.HasSuffix(teamKey, "A") {
+		magnitude = 0
+		teamKey = strings.TrimSuffix(teamKey, "A")
+	} else if strings.HasSuffix(teamKey, "B") {
+		magnitude = 9
+		teamKey = strings.TrimSuffix(teamKey, "B")
+	} else if strings.HasSuffix(teamKey, "C") {
+		magnitude = 8
+		teamKey = strings.TrimSuffix(teamKey, "C")
+	} else if strings.HasSuffix(teamKey, "D") {
+		magnitude = 7
+		teamKey = strings.TrimSuffix(teamKey, "D")
+	} else if strings.HasSuffix(teamKey, "E") {
+		magnitude = 6
+		teamKey = strings.TrimSuffix(teamKey, "E")
+	} else if strings.HasSuffix(teamKey, "F") {
+		magnitude = 5
+		teamKey = strings.TrimSuffix(teamKey, "F")
+	}
+
+	if magnitude != 0 {
+		teamKey = strconv.Itoa(magnitude) + teamKey
+	}
+
+	result, err := strconv.Atoi(teamKey)
+	return result, err
 }
 
 // Parses the alliance data from the specified match and returns the three red
@@ -445,8 +476,14 @@
 	}
 
 	err = handler.db.AddNotes(db.NotesData{
-		TeamNumber: request.Team(),
-		Notes:      []string{string(request.Notes())},
+		TeamNumber:   request.Team(),
+		Notes:        string(request.Notes()),
+		GoodDriving:  bool(request.GoodDriving()),
+		BadDriving:   bool(request.BadDriving()),
+		SketchyClimb: bool(request.SketchyClimb()),
+		SolidClimb:   bool(request.SolidClimb()),
+		GoodDefense:  bool(request.GoodDefense()),
+		BadDefense:   bool(request.BadDefense()),
 	})
 	if err != nil {
 		respondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to insert notes: %v", err))
@@ -475,14 +512,14 @@
 		return
 	}
 
-	notesData, err := handler.db.QueryNotes(request.Team())
+	notes, err := handler.db.QueryNotes(request.Team())
 	if err != nil {
 		respondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to query notes: %v", err))
 		return
 	}
 
 	var response RequestNotesForTeamResponseT
-	for _, data := range notesData.Notes {
+	for _, data := range notes {
 		response.Notes = append(response.Notes, &request_notes_for_team_response.NoteT{data})
 	}
 
@@ -576,6 +613,40 @@
 	w.Write(builder.FinishedBytes())
 }
 
+type SubmitDriverRankingHandler struct {
+	db Database
+}
+
+func (handler SubmitDriverRankingHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	requestBytes, err := io.ReadAll(req.Body)
+	if err != nil {
+		respondWithError(w, http.StatusBadRequest, fmt.Sprint("Failed to read request bytes:", err))
+		return
+	}
+
+	request, success := parseRequest(w, requestBytes, "SubmitDriverRanking", submit_driver_ranking.GetRootAsSubmitDriverRanking)
+	if !success {
+		return
+	}
+
+	err = handler.db.AddDriverRanking(db.DriverRankingData{
+		MatchNumber: request.MatchNumber(),
+		Rank1:       request.Rank1(),
+		Rank2:       request.Rank2(),
+		Rank3:       request.Rank3(),
+	})
+
+	if err != nil {
+		respondWithError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to insert driver ranking: %v", err))
+		return
+	}
+
+	var response SubmitDriverRankingResponseT
+	builder := flatbuffers.NewBuilder(10)
+	builder.Finish((&response).Pack(builder))
+	w.Write(builder.FinishedBytes())
+}
+
 func HandleRequests(db Database, scrape ScrapeMatchList, scoutingServer server.ScoutingServer) {
 	scoutingServer.HandleFunc("/requests", unknown)
 	scoutingServer.Handle("/requests/submit/data_scouting", submitDataScoutingHandler{db})
@@ -587,4 +658,5 @@
 	scoutingServer.Handle("/requests/request/notes_for_team", requestNotesForTeamHandler{db})
 	scoutingServer.Handle("/requests/submit/shift_schedule", submitShiftScheduleHandler{db})
 	scoutingServer.Handle("/requests/request/shift_schedule", requestShiftScheduleHandler{db})
+	scoutingServer.Handle("/requests/submit/submit_driver_ranking", SubmitDriverRankingHandler{db})
 }
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index 44fd5db..55b789b 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -24,6 +24,7 @@
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/request_shift_schedule_response"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_data_scouting_response"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_driver_ranking"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_notes"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests/messages/submit_shift_schedule"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
@@ -314,8 +315,14 @@
 
 	builder := flatbuffers.NewBuilder(1024)
 	builder.Finish((&submit_notes.SubmitNotesT{
-		Team:  971,
-		Notes: "Notes",
+		Team:         971,
+		Notes:        "Notes",
+		GoodDriving:  true,
+		BadDriving:   false,
+		SketchyClimb: true,
+		SolidClimb:   false,
+		GoodDefense:  true,
+		BadDefense:   false,
 	}).Pack(builder))
 
 	_, err := debug.SubmitNotes("http://localhost:8080", builder.FinishedBytes())
@@ -324,7 +331,16 @@
 	}
 
 	expected := []db.NotesData{
-		{TeamNumber: 971, Notes: []string{"Notes"}},
+		{
+			TeamNumber:   971,
+			Notes:        "Notes",
+			GoodDriving:  true,
+			BadDriving:   false,
+			SketchyClimb: true,
+			SolidClimb:   false,
+			GoodDefense:  true,
+			BadDefense:   false,
+		},
 	}
 
 	if !reflect.DeepEqual(database.notes, expected) {
@@ -335,8 +351,14 @@
 func TestRequestNotes(t *testing.T) {
 	database := MockDatabase{
 		notes: []db.NotesData{{
-			TeamNumber: 971,
-			Notes:      []string{"Notes"},
+			TeamNumber:   971,
+			Notes:        "Notes",
+			GoodDriving:  true,
+			BadDriving:   false,
+			SketchyClimb: true,
+			SolidClimb:   false,
+			GoodDefense:  true,
+			BadDefense:   false,
 		}},
 	}
 	scoutingServer := server.NewScoutingServer()
@@ -539,14 +561,44 @@
 	}
 }
 
+func TestSubmitDriverRanking(t *testing.T) {
+	database := MockDatabase{}
+	scoutingServer := server.NewScoutingServer()
+	HandleRequests(&database, scrapeEmtpyMatchList, scoutingServer)
+	scoutingServer.Start(8080)
+	defer scoutingServer.Stop()
+
+	builder := flatbuffers.NewBuilder(1024)
+	builder.Finish((&submit_driver_ranking.SubmitDriverRankingT{
+		MatchNumber: 36,
+		Rank1:       1234,
+		Rank2:       1235,
+		Rank3:       1236,
+	}).Pack(builder))
+
+	_, err := debug.SubmitDriverRanking("http://localhost:8080", builder.FinishedBytes())
+	if err != nil {
+		t.Fatal("Failed to submit driver ranking: ", err)
+	}
+
+	expected := []db.DriverRankingData{
+		{MatchNumber: 36, Rank1: 1234, Rank2: 1235, Rank3: 1236},
+	}
+
+	if !reflect.DeepEqual(database.driver_ranking, expected) {
+		t.Fatal("Submitted notes did not match", expected, database.notes)
+	}
+}
+
 // A mocked database we can use for testing. Add functionality to this as
 // needed for your tests.
 
 type MockDatabase struct {
-	matches       []db.Match
-	stats         []db.Stats
-	notes         []db.NotesData
-	shiftSchedule []db.Shift
+	matches        []db.Match
+	stats          []db.Stats
+	notes          []db.NotesData
+	shiftSchedule  []db.Shift
+	driver_ranking []db.DriverRankingData
 }
 
 func (database *MockDatabase) AddToMatch(match db.Match) error {
@@ -584,14 +636,14 @@
 	return []db.Stats{}, nil
 }
 
-func (database *MockDatabase) QueryNotes(requestedTeam int32) (db.NotesData, error) {
+func (database *MockDatabase) QueryNotes(requestedTeam int32) ([]string, error) {
 	var results []string
 	for _, data := range database.notes {
 		if data.TeamNumber == requestedTeam {
-			results = append(results, data.Notes[0])
+			results = append(results, data.Notes)
 		}
 	}
-	return db.NotesData{TeamNumber: requestedTeam, Notes: results}, nil
+	return results, nil
 }
 
 func (database *MockDatabase) AddNotes(data db.NotesData) error {
@@ -612,6 +664,11 @@
 	return []db.Shift{}, nil
 }
 
+func (database *MockDatabase) AddDriverRanking(data db.DriverRankingData) error {
+	database.driver_ranking = append(database.driver_ranking, data)
+	return nil
+}
+
 // Returns an empty match list from the fake The Blue Alliance scraping.
 func scrapeEmtpyMatchList(int32, string) ([]scraping.Match, error) {
 	return nil, nil
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index 0b7cebb..ee0659b 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -16,6 +16,7 @@
     use_angular_plugin = True,
     visibility = ["//visibility:public"],
     deps = [
+        "//scouting/www/driver_ranking",
         "//scouting/www/entry",
         "//scouting/www/import_match_list",
         "//scouting/www/match_list",
diff --git a/scouting/www/app.ng.html b/scouting/www/app.ng.html
index 297fd39..d9dbead 100644
--- a/scouting/www/app.ng.html
+++ b/scouting/www/app.ng.html
@@ -40,6 +40,15 @@
   <li class="nav-item">
     <a
       class="nav-link"
+      [class.active]="tabIs('DriverRanking')"
+      (click)="switchTabToGuarded('DriverRanking')"
+    >
+      Driver Ranking
+    </a>
+  </li>
+  <li class="nav-item">
+    <a
+      class="nav-link"
       [class.active]="tabIs('ImportMatchList')"
       (click)="switchTabToGuarded('ImportMatchList')"
     >
@@ -80,6 +89,7 @@
     *ngSwitchCase="'Entry'"
   ></app-entry>
   <frc971-notes *ngSwitchCase="'Notes'"></frc971-notes>
+  <app-driver-ranking *ngSwitchCase="'DriverRanking'"></app-driver-ranking>
   <app-import-match-list
     *ngSwitchCase="'ImportMatchList'"
   ></app-import-match-list>
diff --git a/scouting/www/app.ts b/scouting/www/app.ts
index 4f95c90..b26f815 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app.ts
@@ -4,6 +4,7 @@
   | 'MatchList'
   | 'Notes'
   | 'Entry'
+  | 'DriverRanking'
   | 'ImportMatchList'
   | 'ShiftSchedule'
   | 'View';
diff --git a/scouting/www/app_module.ts b/scouting/www/app_module.ts
index 8c18f7a..04d72b3 100644
--- a/scouting/www/app_module.ts
+++ b/scouting/www/app_module.ts
@@ -9,6 +9,7 @@
 import {NotesModule} from './notes/notes.module';
 import {ShiftScheduleModule} from './shift_schedule/shift_schedule.module';
 import {ViewModule} from './view/view.module';
+import {DriverRankingModule} from './driver_ranking/driver_ranking.module';
 
 @NgModule({
   declarations: [App],
@@ -20,6 +21,7 @@
     ImportMatchListModule,
     MatchListModule,
     ShiftScheduleModule,
+    DriverRankingModule,
     ViewModule,
   ],
   exports: [App],
diff --git a/scouting/www/driver_ranking/BUILD b/scouting/www/driver_ranking/BUILD
new file mode 100644
index 0000000..10b6f99
--- /dev/null
+++ b/scouting/www/driver_ranking/BUILD
@@ -0,0 +1,26 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+
+ts_library(
+    name = "driver_ranking",
+    srcs = [
+        "driver_ranking.component.ts",
+        "driver_ranking.module.ts",
+    ],
+    angular_assets = [
+        "driver_ranking.component.css",
+        "driver_ranking.ng.html",
+        "//scouting/www:common_css",
+    ],
+    compiler = "//tools:tsc_wrapped_with_angular",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    use_angular_plugin = True,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//scouting/webserver/requests/messages:error_response_ts_fbs",
+        "//scouting/webserver/requests/messages:submit_driver_ranking_ts_fbs",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        "@npm//@angular/common",
+        "@npm//@angular/core",
+        "@npm//@angular/forms",
+    ],
+)
diff --git a/scouting/www/driver_ranking/driver_ranking.component.css b/scouting/www/driver_ranking/driver_ranking.component.css
new file mode 100644
index 0000000..e220645
--- /dev/null
+++ b/scouting/www/driver_ranking/driver_ranking.component.css
@@ -0,0 +1,3 @@
+* {
+  padding: 10px;
+}
diff --git a/scouting/www/driver_ranking/driver_ranking.component.ts b/scouting/www/driver_ranking/driver_ranking.component.ts
new file mode 100644
index 0000000..aadb3b0
--- /dev/null
+++ b/scouting/www/driver_ranking/driver_ranking.component.ts
@@ -0,0 +1,93 @@
+import {Component, OnInit} from '@angular/core';
+import {Builder, ByteBuffer} from 'flatbuffers';
+import {SubmitDriverRanking} from 'org_frc971/scouting/webserver/requests/messages/submit_driver_ranking_generated';
+import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
+
+// TeamSelection: Display form to input which
+// teams to rank and the match number.
+// Data: Display the ranking interface where
+// the scout can reorder teams and submit data.
+type Section = 'TeamSelection' | 'Data';
+
+@Component({
+  selector: 'app-driver-ranking',
+  templateUrl: './driver_ranking.ng.html',
+  styleUrls: ['../common.css', './driver_ranking.component.css'],
+})
+export class DriverRankingComponent {
+  section: Section = 'TeamSelection';
+
+  // Stores the team keys and rank (order of the array).
+  team_ranking: number[] = [971, 972, 973];
+
+  match_number: number = 1;
+
+  errorMessage = '';
+
+  setTeamNumbers() {
+    this.section = 'Data';
+  }
+
+  rankUp(index: number) {
+    if (index > 0) {
+      this.changeRank(index, index - 1);
+    }
+  }
+
+  rankDown(index: number) {
+    if (index < 2) {
+      this.changeRank(index, index + 1);
+    }
+  }
+
+  // Change the rank of a team in team_ranking.
+  // Move the the team at index 'fromIndex'
+  // to the index 'toIndex'.
+  // Ex. Moving the rank 2 (index 1) team to rank1 (index 0)
+  // would be changeRank(1, 0)
+
+  changeRank(fromIndex: number, toIndex: number) {
+    var element = this.team_ranking[fromIndex];
+    this.team_ranking.splice(fromIndex, 1);
+    this.team_ranking.splice(toIndex, 0, element);
+  }
+
+  editTeams() {
+    this.section = 'TeamSelection';
+  }
+
+  async submitData() {
+    const builder = new Builder();
+    builder.finish(
+      SubmitDriverRanking.createSubmitDriverRanking(
+        builder,
+        this.match_number,
+        this.team_ranking[0],
+        this.team_ranking[1],
+        this.team_ranking[2]
+      )
+    );
+    const buffer = builder.asUint8Array();
+    const res = await fetch('/requests/submit/submit_driver_ranking', {
+      method: 'POST',
+      body: buffer,
+    });
+
+    if (!res.ok) {
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+
+      const errorMessage = parsedResponse.errorMessage();
+      this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
+      return;
+    }
+
+    // Increment the match number.
+    this.match_number = this.match_number + 1;
+
+    // Reset Data.
+    this.section = 'TeamSelection';
+    this.team_ranking = [971, 972, 973];
+  }
+}
diff --git a/scouting/www/driver_ranking/driver_ranking.module.ts b/scouting/www/driver_ranking/driver_ranking.module.ts
new file mode 100644
index 0000000..7fe3623
--- /dev/null
+++ b/scouting/www/driver_ranking/driver_ranking.module.ts
@@ -0,0 +1,11 @@
+import {CommonModule} from '@angular/common';
+import {NgModule} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {DriverRankingComponent} from './driver_ranking.component';
+
+@NgModule({
+  declarations: [DriverRankingComponent],
+  exports: [DriverRankingComponent],
+  imports: [CommonModule, FormsModule],
+})
+export class DriverRankingModule {}
diff --git a/scouting/www/driver_ranking/driver_ranking.ng.html b/scouting/www/driver_ranking/driver_ranking.ng.html
new file mode 100644
index 0000000..452359c
--- /dev/null
+++ b/scouting/www/driver_ranking/driver_ranking.ng.html
@@ -0,0 +1,80 @@
+<h2 id="page-title">Driver Ranking</h2>
+
+<ng-container [ngSwitch]="section">
+  <div *ngSwitchCase="'TeamSelection'">
+    <label for="match_number_selection">Match Number</label>
+    <input
+      [(ngModel)]="match_number"
+      type="number"
+      id="match_number_selection"
+      min="1"
+      max="9999"
+    />
+    <br />
+    <br />
+    <label>Team Numbers</label>
+    <input
+      *ngFor="let x of [1,2,3]; let i = index;"
+      [(ngModel)]="team_ranking[i]"
+      type="number"
+      min="1"
+      max="9999"
+      id="team_input_{{i}}"
+    />
+    <button
+      class="btn btn-primary"
+      (click)="setTeamNumbers()"
+      id="select_button"
+    >
+      Select
+    </button>
+  </div>
+  <div *ngSwitchCase="'Data'">
+    <h4 id="match_number_heading">Match #{{match_number}}</h4>
+    <div *ngFor="let team_key of team_ranking; let i = index">
+      <div class="d-flex flex-row justify-content-center pt-2">
+        <div class="d-flex flex-row">
+          <h4 class="align-self-center" id="team_rank_label_{{i}}">
+            {{i + 1}}
+          </h4>
+          <h1 class="align-self-center" id="team_key_label_{{i}}">
+            {{team_key}}
+          </h1>
+        </div>
+        <button
+          class="btn btn-success"
+          (click)="rankUp(i)"
+          id="up_button_{{i}}"
+        >
+          &uarr;
+        </button>
+        <!--&uarr; is the html code for an up arrow-->
+        <button
+          class="btn btn-danger"
+          (click)="rankDown(i)"
+          id="down_button_{{i}}"
+        >
+          &darr;
+        </button>
+        <!--&darr; is the html code for a down arrow-->
+      </div>
+    </div>
+    <div class="d-flex flex-row justify-content-center pt-2">
+      <div>
+        <button class="btn btn-secondary" (click)="editTeams()">
+          Edit Teams
+        </button>
+      </div>
+      <div>
+        <button
+          class="btn btn-success"
+          (click)="submitData()"
+          id="submit_button"
+        >
+          Submit
+        </button>
+      </div>
+    </div>
+  </div>
+  <div class="error">{{errorMessage}}</div>
+</ng-container>
diff --git a/scouting/www/entry/entry.component.css b/scouting/www/entry/entry.component.css
index 6d13657..a78a00a 100644
--- a/scouting/www/entry/entry.component.css
+++ b/scouting/www/entry/entry.component.css
@@ -8,8 +8,8 @@
 }
 
 textarea {
-  width: 300px;
-  height: 150px;
+  width: 350px;
+  height: 180px;
 }
 
 button {
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index 9be8db7..e73cfb0 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -254,10 +254,6 @@
       </label>
       <br />
     </form>
-    <div class="row">
-      <h4>Comments</h4>
-      <textarea [(ngModel)]="comment" id="comment"></textarea>
-    </div>
     <div class="buttons">
       <button class="btn btn-primary" (click)="prevSection()">Back</button>
       <button class="btn btn-primary" (click)="nextSection()">Next</button>
@@ -360,6 +356,15 @@
       </form>
     </div>
 
+    <div class="row">
+      <h4>General Comments About Match</h4>
+      <textarea
+        [(ngModel)]="comment"
+        id="comment"
+        placeholder="optional"
+      ></textarea>
+    </div>
+
     <div class="buttons">
       <button class="btn btn-primary" (click)="prevSection()">Back</button>
       <button class="btn btn-primary" (click)="nextSection()">Next</button>
@@ -398,7 +403,6 @@
     <h4>Climb</h4>
     <ul>
       <li>Climb Level: {{level | levelToString}}</li>
-      <li>Comments: {{comment}}</li>
     </ul>
 
     <h4>Other</h4>
@@ -410,6 +414,7 @@
       <li>Battery died: {{batteryDied}}</li>
       <li>Broke (mechanically): {{mechanicallyBroke}}</li>
       <li>Lost coms: {{lostComs}}</li>
+      <li>Comments: {{comment}}</li>
     </ul>
 
     <span class="error_message">{{ errorMessage }}</span>
diff --git a/scouting/www/notes/notes.component.css b/scouting/www/notes/notes.component.css
index 869bdab..b601507 100644
--- a/scouting/www/notes/notes.component.css
+++ b/scouting/www/notes/notes.component.css
@@ -4,9 +4,9 @@
 
 .text-input {
   width: calc(100% - 20px);
+  height: 100px;
 }
 
-.buttons {
-  display: flex;
-  justify-content: space-between;
+.container-main {
+  padding-left: 20px;
 }
diff --git a/scouting/www/notes/notes.component.ts b/scouting/www/notes/notes.component.ts
index 0f0eb82..f503e2d 100644
--- a/scouting/www/notes/notes.component.ts
+++ b/scouting/www/notes/notes.component.ts
@@ -1,4 +1,4 @@
-import {Component} from '@angular/core';
+import {Component, HostListener} from '@angular/core';
 import {Builder, ByteBuffer} from 'flatbuffers';
 import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
 import {RequestNotesForTeam} from 'org_frc971/scouting/webserver/requests/messages/request_notes_for_team_generated';
@@ -9,88 +9,170 @@
 import {SubmitNotes} from 'org_frc971/scouting/webserver/requests/messages/submit_notes_generated';
 import {SubmitNotesResponse} from 'org_frc971/scouting/webserver/requests/messages/submit_notes_response_generated';
 
+/*
+For new games, the keywords being used will likely need to be updated.
+To update the keywords complete the following: 
+  1) Update the Keywords Interface and KEYWORD_CHECKBOX_LABELS in notes.component.ts
+    The keys of Keywords and KEYWORD_CHECKBOX_LABELS should match.
+  2) In notes.component.ts, update the setTeamNumber() method with the new keywords.
+  3) Add/Edit the new keywords in /scouting/webserver/requests/messages/submit_notes.fbs.
+  4) In notes.component.ts, update the submitData() method with the newKeywords 
+    so that it matches the updated flatbuffer
+  5) In db.go, update the NotesData struct and the 
+    AddNotes method with the new keywords        
+  6) In db_test.go update the TestNotes method so the test uses the keywords
+  7) Update the submitNoteScoutingHandler in requests.go with the new keywords
+  8) Finally, update the corresponding test in requests_test.go (TestSubmitNotes)
+  
+  Note: If you change the number of keywords you might need to 
+    update how they are displayed in notes.ng.html 
+*/
+
+// TeamSelection: Display form to add a team to the teams being scouted.
+// Data: Display the note textbox and keyword selection form
+// for all the teams being scouted.
 type Section = 'TeamSelection' | 'Data';
 
-interface Note {
-  readonly data: string;
+// Every keyword checkbox corresponds to a boolean.
+// If the boolean is True, the checkbox is selected
+// and the note scout saw that the robot being scouted
+// displayed said property (ex. Driving really well -> goodDriving)
+interface Keywords {
+  goodDriving: boolean;
+  badDriving: boolean;
+  sketchyClimb: boolean;
+  solidClimb: boolean;
+  goodDefense: boolean;
+  badDefense: boolean;
 }
 
+interface Input {
+  teamNumber: number;
+  notesData: string;
+  keywordsData: Keywords;
+}
+
+const KEYWORD_CHECKBOX_LABELS = {
+  goodDriving: 'Good Driving',
+  badDriving: 'Bad Driving',
+  solidClimb: 'Solid Climb',
+  sketchyClimb: 'Sketchy Climb',
+  goodDefense: 'Good Defense',
+  badDefense: 'Bad Defense',
+} as const;
+
 @Component({
   selector: 'frc971-notes',
   templateUrl: './notes.ng.html',
   styleUrls: ['../common.css', './notes.component.css'],
 })
 export class Notes {
+  // Re-export KEYWORD_CHECKBOX_LABELS so that we can
+  // use it in the checkbox properties.
+  readonly KEYWORD_CHECKBOX_LABELS = KEYWORD_CHECKBOX_LABELS;
+
+  // Necessary in order to iterate the keys of KEYWORD_CHECKBOX_LABELS.
+  Object = Object;
+
   section: Section = 'TeamSelection';
-  notes: Note[] = [];
 
   errorMessage = '';
+  teamNumberSelection: number = 971;
 
-  teamNumber: number = 971;
-  newData = '';
+  // Data inputted by user is stored in this array.
+  // Includes the team number, notes, and keyword selection.
+  newData: Input[] = [];
 
-  async setTeamNumber() {
-    const builder = new Builder();
-    RequestNotesForTeam.startRequestNotesForTeam(builder);
-    RequestNotesForTeam.addTeam(builder, this.teamNumber);
-    builder.finish(RequestNotesForTeam.endRequestNotesForTeam(builder));
+  // Keyboard shortcuts to switch between text areas.
+  // Listens for Ctrl + number and focuses on the
+  // corresponding textbox.
+  // More Info: https://angular.io/api/core/HostListener
 
-    const buffer = builder.asUint8Array();
-    const res = await fetch('/requests/request/notes_for_team', {
-      method: 'POST',
-      body: buffer,
-    });
-
-    const resBuffer = await res.arrayBuffer();
-    const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
-
-    if (res.ok) {
-      this.notes = [];
-      const parsedResponse =
-        RequestNotesForTeamResponse.getRootAsRequestNotesForTeamResponse(
-          fbBuffer
-        );
-      for (let i = 0; i < parsedResponse.notesLength(); i++) {
-        const fbNote = parsedResponse.notes(i);
-        this.notes.push({data: fbNote.data()});
+  @HostListener('window:keyup', ['$event'])
+  onEvent(event: KeyboardEvent) {
+    if (event.ctrlKey) {
+      if (event.code.includes('Digit')) {
+        this.handleFocus(event.key);
       }
-      this.section = 'Data';
-    } else {
-      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
-
-      const errorMessage = parsedResponse.errorMessage();
-      this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
     }
   }
 
-  changeTeam() {
+  handleFocus(digit: string) {
+    let textArea = <HTMLInputElement>(
+      document.getElementById('text-input-' + digit)
+    );
+    if (textArea != null) {
+      textArea.focus();
+    }
+  }
+
+  setTeamNumber() {
+    let data: Input = {
+      teamNumber: this.teamNumberSelection,
+      notesData: 'Auto: \nTeleop: \nEngame: ',
+      keywordsData: {
+        goodDriving: false,
+        badDriving: false,
+        solidClimb: false,
+        sketchyClimb: false,
+        goodDefense: false,
+        badDefense: false,
+      },
+    };
+
+    this.newData.push(data);
+    this.section = 'Data';
+  }
+
+  removeTeam(index: number) {
+    this.newData.splice(index, 1);
+    if (this.newData.length == 0) {
+      this.section = 'TeamSelection';
+    } else {
+      this.section = 'Data';
+    }
+  }
+
+  addTeam() {
     this.section = 'TeamSelection';
   }
 
   async submitData() {
-    const builder = new Builder();
-    const dataFb = builder.createString(this.newData);
-    builder.finish(
-      SubmitNotes.createSubmitNotes(builder, this.teamNumber, dataFb)
-    );
+    for (let i = 0; i < this.newData.length; i++) {
+      const builder = new Builder();
+      const dataFb = builder.createString(this.newData[i].notesData);
 
-    const buffer = builder.asUint8Array();
-    const res = await fetch('/requests/submit/submit_notes', {
-      method: 'POST',
-      body: buffer,
-    });
+      builder.finish(
+        SubmitNotes.createSubmitNotes(
+          builder,
+          this.newData[i].teamNumber,
+          dataFb,
+          this.newData[i].keywordsData.goodDriving,
+          this.newData[i].keywordsData.badDriving,
+          this.newData[i].keywordsData.sketchyClimb,
+          this.newData[i].keywordsData.solidClimb,
+          this.newData[i].keywordsData.goodDefense,
+          this.newData[i].keywordsData.badDefense
+        )
+      );
 
-    if (res.ok) {
-      this.newData = '';
-      this.errorMessage = '';
-      await this.setTeamNumber();
-    } else {
-      const resBuffer = await res.arrayBuffer();
-      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
-      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+      const buffer = builder.asUint8Array();
+      const res = await fetch('/requests/submit/submit_notes', {
+        method: 'POST',
+        body: buffer,
+      });
 
-      const errorMessage = parsedResponse.errorMessage();
-      this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
+      if (!res.ok) {
+        const resBuffer = await res.arrayBuffer();
+        const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+        const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+        const errorMessage = parsedResponse.errorMessage();
+        this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
+      }
     }
+
+    this.newData = [];
+    this.errorMessage = '';
+    this.section = 'TeamSelection';
   }
 }
diff --git a/scouting/www/notes/notes.ng.html b/scouting/www/notes/notes.ng.html
index a69ba88..b51a7ce 100644
--- a/scouting/www/notes/notes.ng.html
+++ b/scouting/www/notes/notes.ng.html
@@ -1,10 +1,12 @@
-<h2>Notes</h2>
+<h2 id="page-title">Notes</h2>
 
 <ng-container [ngSwitch]="section">
   <div *ngSwitchCase="'TeamSelection'">
-    <label for="team_number_notes">Team Number</label>
+    <label id="team_number_label" class="label" for="team_number_notes">
+      Team Number
+    </label>
     <input
-      [(ngModel)]="teamNumber"
+      [(ngModel)]="teamNumberSelection"
       type="number"
       id="team_number_notes"
       min="1"
@@ -14,17 +16,107 @@
   </div>
 
   <div *ngSwitchCase="'Data'">
-    <h3>Scouting team: {{teamNumber}}</h3>
-    <ul *ngFor="let note of notes">
-      <li class="note">{{ note.data }}</li>
-    </ul>
-    <textarea class="text-input" [(ngModel)]="newData"></textarea>
-    <div class="buttons">
-      <button class="btn btn-primary" (click)="changeTeam()">
-        Change team
-      </button>
-      <button class="btn btn-primary" (click)="submitData()">Submit</button>
+    <div class="container-main" *ngFor="let team of newData; let i = index">
+      <div class="pt-2 pb-2">
+        <div class="d-flex flex-row">
+          <div>
+            <button
+              class="btn bg-transparent ml-10 md-5"
+              (click)="removeTeam(i)"
+            >
+              &#10006;
+              <!--X Symbol-->
+            </button>
+          </div>
+          <div><h3 id="team-key-{{i+1}}">{{team.teamNumber}}</h3></div>
+        </div>
+        <div class="">
+          <!--
+          Note Input Text Areas.
+          ID property is used for keyboard shorcuts to focus
+          on the corresponding text area.
+          The data-toggle and title properties are
+          used for bootstrap tooltips.
+          -->
+          <textarea
+            class="text-input"
+            id="text-input-{{i+1}}"
+            [(ngModel)]="newData[i].notesData"
+            data-toggle="tooltip"
+            title="Ctrl + {{i+1}}"
+          ></textarea>
+        </div>
+        <!--Key Word Checkboxes-->
+        <!--Row 1 (Prevent Overflow on mobile by splitting checkboxes into 2 rows)-->
+        <!--Slice KEYWORD_CHECKBOX_LABELS using https://angular.io/api/common/SlicePipe-->
+        <div class="d-flex flex-row justify-content-around">
+          <div
+            *ngFor="let key of Object.keys(KEYWORD_CHECKBOX_LABELS) | slice:0:((Object.keys(KEYWORD_CHECKBOX_LABELS).length)/2); let k = index"
+          >
+            <div class="form-check">
+              <input
+                class="form-check-input"
+                [(ngModel)]="newData[i]['keywordsData'][key]"
+                type="checkbox"
+                id="{{KEYWORD_CHECKBOX_LABELS[key]}}_{{i}}"
+                name="{{KEYWORD_CHECKBOX_LABELS[key]}}"
+              />
+              <label
+                class="form-check-label"
+                for="{{KEYWORD_CHECKBOX_LABELS[key]}}_{{i}}"
+              >
+                {{KEYWORD_CHECKBOX_LABELS[key]}}
+              </label>
+              <br />
+            </div>
+          </div>
+        </div>
+        <!--Row 2 (Prevent Overflow on mobile by splitting checkboxes into 2 rows)-->
+        <div class="d-flex flex-row justify-content-around">
+          <div
+            *ngFor="let key of Object.keys(KEYWORD_CHECKBOX_LABELS) | slice:3:(Object.keys(KEYWORD_CHECKBOX_LABELS).length); let k = index"
+          >
+            <div class="form-check">
+              <input
+                class="form-check-input"
+                [(ngModel)]="newData[i]['keywordsData'][key]"
+                type="checkbox"
+                id="{{KEYWORD_CHECKBOX_LABELS[key]}}"
+                name="{{KEYWORD_CHECKBOX_LABELS[key]}}"
+              />
+              <label
+                class="form-check-label"
+                for="{{KEYWORD_CHECKBOX_LABELS[key]}}"
+              >
+                {{KEYWORD_CHECKBOX_LABELS[key]}}
+              </label>
+              <br />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="d-flex flex-row justify-content-center pt-2">
+      <div>
+        <button
+          id="add-team-button"
+          class="btn btn-secondary"
+          (click)="addTeam()"
+        >
+          Add team
+        </button>
+      </div>
+      <div>
+        <button
+          id="submit-button"
+          class="btn btn-success"
+          (click)="submitData()"
+        >
+          Submit
+        </button>
+      </div>
     </div>
   </div>
+
   <div class="error">{{errorMessage}}</div>
 </ng-container>
diff --git a/tools/go/go_mirrors.bzl b/tools/go/go_mirrors.bzl
index ee8b1b8..338cc95 100644
--- a/tools/go/go_mirrors.bzl
+++ b/tools/go/go_mirrors.bzl
@@ -1,11 +1,11 @@
 # This file is auto-generated. Do not edit.
 GO_MIRROR_INFO = {
     "co_honnef_go_tools": {
-        "filename": "co_honnef_go_tools__v0.0.0-20190523083050-ea95bdfd59fc.zip",
+        "filename": "co_honnef_go_tools__v0.0.1-2019.2.3.zip",
         "importpath": "honnef.co/go/tools",
-        "sha256": "eeaa82700e96ac5e803d7a9c32363332504beff8fbb1202492b4d43d5a5e7360",
-        "strip_prefix": "honnef.co/go/tools@v0.0.0-20190523083050-ea95bdfd59fc",
-        "version": "v0.0.0-20190523083050-ea95bdfd59fc",
+        "sha256": "539825114c487680f99df80f6107410e1e53bbfd5deb931b84d1faf2d221638e",
+        "strip_prefix": "honnef.co/go/tools@v0.0.1-2019.2.3",
+        "version": "v0.0.1-2019.2.3",
     },
     "com_github_antihax_optional": {
         "filename": "com_github_antihax_optional__v1.0.0.zip",
@@ -77,6 +77,20 @@
         "strip_prefix": "github.com/cockroachdb/apd@v1.1.0",
         "version": "v1.1.0",
     },
+    "com_github_coreos_go_systemd": {
+        "filename": "com_github_coreos_go_systemd__v0.0.0-20190719114852-fd7a80b32e1f.zip",
+        "importpath": "github.com/coreos/go-systemd",
+        "sha256": "22237f0aed3ab6018a1025c65f4f45b4c05f9aa0c0bb9ec880294273b9a15bf2",
+        "strip_prefix": "github.com/coreos/go-systemd@v0.0.0-20190719114852-fd7a80b32e1f",
+        "version": "v0.0.0-20190719114852-fd7a80b32e1f",
+    },
+    "com_github_creack_pty": {
+        "filename": "com_github_creack_pty__v1.1.7.zip",
+        "importpath": "github.com/creack/pty",
+        "sha256": "e7ea3403784d186aefbe84caed958f8cba2e72a04f30cdb291ece19bec39c8f3",
+        "strip_prefix": "github.com/creack/pty@v1.1.7",
+        "version": "v1.1.7",
+    },
     "com_github_davecgh_go_spew": {
         "filename": "com_github_davecgh_go_spew__v1.1.1.zip",
         "importpath": "github.com/davecgh/go-spew",
@@ -105,6 +119,27 @@
         "strip_prefix": "github.com/ghodss/yaml@v1.0.0",
         "version": "v1.0.0",
     },
+    "com_github_go_kit_log": {
+        "filename": "com_github_go_kit_log__v0.1.0.zip",
+        "importpath": "github.com/go-kit/log",
+        "sha256": "e0676df7357654a000008dfad3b6b211cba3595f32d3e220edd63a4c9d0d9254",
+        "strip_prefix": "github.com/go-kit/log@v0.1.0",
+        "version": "v0.1.0",
+    },
+    "com_github_go_logfmt_logfmt": {
+        "filename": "com_github_go_logfmt_logfmt__v0.5.0.zip",
+        "importpath": "github.com/go-logfmt/logfmt",
+        "sha256": "59a6b59ae3da84f7a58373844ca8d298f5007ce0e173437fc85c26d4fc76ca8b",
+        "strip_prefix": "github.com/go-logfmt/logfmt@v0.5.0",
+        "version": "v0.5.0",
+    },
+    "com_github_go_stack_stack": {
+        "filename": "com_github_go_stack_stack__v1.8.0.zip",
+        "importpath": "github.com/go-stack/stack",
+        "sha256": "78c2667c710f811307038634ffa43af442619acfeaf1efb593aa4e0ded9df48f",
+        "strip_prefix": "github.com/go-stack/stack@v1.8.0",
+        "version": "v1.8.0",
+    },
     "com_github_gofrs_uuid": {
         "filename": "com_github_gofrs_uuid__v4.0.0+incompatible.zip",
         "importpath": "github.com/gofrs/uuid",
@@ -147,6 +182,13 @@
         "strip_prefix": "github.com/google/go-querystring@v1.1.0",
         "version": "v1.1.0",
     },
+    "com_github_google_renameio": {
+        "filename": "com_github_google_renameio__v0.1.0.zip",
+        "importpath": "github.com/google/renameio",
+        "sha256": "b8510bb34078691a20b8e4902d371afe0eb171b2daf953f67cb3960d1926ccf3",
+        "strip_prefix": "github.com/google/renameio@v0.1.0",
+        "version": "v0.1.0",
+    },
     "com_github_google_uuid": {
         "filename": "com_github_google_uuid__v1.1.2.zip",
         "importpath": "github.com/google/uuid",
@@ -161,19 +203,138 @@
         "strip_prefix": "github.com/grpc-ecosystem/grpc-gateway@v1.16.0",
         "version": "v1.16.0",
     },
-    "com_github_jackc_fake": {
-        "filename": "com_github_jackc_fake__v0.0.0-20150926172116-812a484cc733.zip",
-        "importpath": "github.com/jackc/fake",
-        "sha256": "bf8b5b51ae03f572a70a0582dc663c5733bba9aca785d39bb0367797148e6d64",
-        "strip_prefix": "github.com/jackc/fake@v0.0.0-20150926172116-812a484cc733",
-        "version": "v0.0.0-20150926172116-812a484cc733",
+    "com_github_jackc_chunkreader": {
+        "filename": "com_github_jackc_chunkreader__v1.0.0.zip",
+        "importpath": "github.com/jackc/chunkreader",
+        "sha256": "e204c917e2652ffe047f5c8b031192757321f568654e3df8408bf04178df1408",
+        "strip_prefix": "github.com/jackc/chunkreader@v1.0.0",
+        "version": "v1.0.0",
     },
-    "com_github_jackc_pgx": {
-        "filename": "com_github_jackc_pgx__v3.6.2+incompatible.zip",
-        "importpath": "github.com/jackc/pgx",
-        "sha256": "73675895baa0da97b2f0ce6e895c69b7c77ad994e30ce6a1add2abc3bb17e375",
-        "strip_prefix": "github.com/jackc/pgx@v3.6.2+incompatible",
-        "version": "v3.6.2+incompatible",
+    "com_github_jackc_chunkreader_v2": {
+        "filename": "com_github_jackc_chunkreader_v2__v2.0.1.zip",
+        "importpath": "github.com/jackc/chunkreader/v2",
+        "sha256": "6e3f4b7d9647f31061f6446ae10de71fc1407e64f84cd0949afac0cd231e8dd2",
+        "strip_prefix": "github.com/jackc/chunkreader/v2@v2.0.1",
+        "version": "v2.0.1",
+    },
+    "com_github_jackc_pgconn": {
+        "filename": "com_github_jackc_pgconn__v1.12.1.zip",
+        "importpath": "github.com/jackc/pgconn",
+        "sha256": "48d34064a1facff7766713d9224502e7376a5d90c1506f99a37c57bfceaf9636",
+        "strip_prefix": "github.com/jackc/pgconn@v1.12.1",
+        "version": "v1.12.1",
+    },
+    "com_github_jackc_pgio": {
+        "filename": "com_github_jackc_pgio__v1.0.0.zip",
+        "importpath": "github.com/jackc/pgio",
+        "sha256": "1a83c03d53f6a40339364cafcbbabb44238203c79ca0c9b98bf582d0df0e0468",
+        "strip_prefix": "github.com/jackc/pgio@v1.0.0",
+        "version": "v1.0.0",
+    },
+    "com_github_jackc_pgmock": {
+        "filename": "com_github_jackc_pgmock__v0.0.0-20210724152146-4ad1a8207f65.zip",
+        "importpath": "github.com/jackc/pgmock",
+        "sha256": "0fffd0a7a67dbdfafa04297e51028c6d2d08cd6691f3b6d78d7ae6502d3d4cf2",
+        "strip_prefix": "github.com/jackc/pgmock@v0.0.0-20210724152146-4ad1a8207f65",
+        "version": "v0.0.0-20210724152146-4ad1a8207f65",
+    },
+    "com_github_jackc_pgpassfile": {
+        "filename": "com_github_jackc_pgpassfile__v1.0.0.zip",
+        "importpath": "github.com/jackc/pgpassfile",
+        "sha256": "1cc79fb0b80f54b568afd3f4648dd1c349f746ad7c379df8d7f9e0eb1cac938b",
+        "strip_prefix": "github.com/jackc/pgpassfile@v1.0.0",
+        "version": "v1.0.0",
+    },
+    "com_github_jackc_pgproto3": {
+        "filename": "com_github_jackc_pgproto3__v1.1.0.zip",
+        "importpath": "github.com/jackc/pgproto3",
+        "sha256": "e3766bee50ed74e49a067b2c4797a2c69015cf104bf3f3624cd483a9e940b4ee",
+        "strip_prefix": "github.com/jackc/pgproto3@v1.1.0",
+        "version": "v1.1.0",
+    },
+    "com_github_jackc_pgproto3_v2": {
+        "filename": "com_github_jackc_pgproto3_v2__v2.3.0.zip",
+        "importpath": "github.com/jackc/pgproto3/v2",
+        "sha256": "6b702c372e13520636243d3be58922968f0630b67e23ba77326ef6ee4cada463",
+        "strip_prefix": "github.com/jackc/pgproto3/v2@v2.3.0",
+        "version": "v2.3.0",
+    },
+    "com_github_jackc_pgservicefile": {
+        "filename": "com_github_jackc_pgservicefile__v0.0.0-20200714003250-2b9c44734f2b.zip",
+        "importpath": "github.com/jackc/pgservicefile",
+        "sha256": "8422a25b9d2b0be05c66ee1ccfdbaab144ce98f1ac678bc647064c560d4cd6e2",
+        "strip_prefix": "github.com/jackc/pgservicefile@v0.0.0-20200714003250-2b9c44734f2b",
+        "version": "v0.0.0-20200714003250-2b9c44734f2b",
+    },
+    "com_github_jackc_pgtype": {
+        "filename": "com_github_jackc_pgtype__v1.11.0.zip",
+        "importpath": "github.com/jackc/pgtype",
+        "sha256": "6a257b81c0bd386d6241219a14ebd41d574a02aeaeb3942670c06441b864dcad",
+        "strip_prefix": "github.com/jackc/pgtype@v1.11.0",
+        "version": "v1.11.0",
+    },
+    "com_github_jackc_pgx_v4": {
+        "filename": "com_github_jackc_pgx_v4__v4.16.1.zip",
+        "importpath": "github.com/jackc/pgx/v4",
+        "sha256": "c3a169a68ff0e56f9f81eee4de4d2fd2a5ec7f4d6be159159325f4863c80bd10",
+        "strip_prefix": "github.com/jackc/pgx/v4@v4.16.1",
+        "version": "v4.16.1",
+    },
+    "com_github_jackc_puddle": {
+        "filename": "com_github_jackc_puddle__v1.2.1.zip",
+        "importpath": "github.com/jackc/puddle",
+        "sha256": "40d73550686666eb1f6df02b65008b2a4c98cfed1254dc4866e6ebe95fbc5c95",
+        "strip_prefix": "github.com/jackc/puddle@v1.2.1",
+        "version": "v1.2.1",
+    },
+    "com_github_jinzhu_inflection": {
+        "filename": "com_github_jinzhu_inflection__v1.0.0.zip",
+        "importpath": "github.com/jinzhu/inflection",
+        "sha256": "cf1087a6f6653ed5f366f85cf0110bbbf581d4e9bc8a4d1a9b56765d94b546c3",
+        "strip_prefix": "github.com/jinzhu/inflection@v1.0.0",
+        "version": "v1.0.0",
+    },
+    "com_github_jinzhu_now": {
+        "filename": "com_github_jinzhu_now__v1.1.4.zip",
+        "importpath": "github.com/jinzhu/now",
+        "sha256": "245473b8e50be3897751ec66dd6be93588de261920e0345b500f692924575872",
+        "strip_prefix": "github.com/jinzhu/now@v1.1.4",
+        "version": "v1.1.4",
+    },
+    "com_github_kisielk_gotool": {
+        "filename": "com_github_kisielk_gotool__v1.0.0.zip",
+        "importpath": "github.com/kisielk/gotool",
+        "sha256": "089dbba6e3aa09944fdb40d72acc86694e8bdde01cfc0f40fe0248309eb80a3f",
+        "strip_prefix": "github.com/kisielk/gotool@v1.0.0",
+        "version": "v1.0.0",
+    },
+    "com_github_konsorten_go_windows_terminal_sequences": {
+        "filename": "com_github_konsorten_go_windows_terminal_sequences__v1.0.2.zip",
+        "importpath": "github.com/konsorten/go-windows-terminal-sequences",
+        "sha256": "4d00d71b8de60bcaf454f8f867210ebcd05e75c0a7c2725904f71aa2f20fb08e",
+        "strip_prefix": "github.com/konsorten/go-windows-terminal-sequences@v1.0.2",
+        "version": "v1.0.2",
+    },
+    "com_github_kr_pretty": {
+        "filename": "com_github_kr_pretty__v0.1.0.zip",
+        "importpath": "github.com/kr/pretty",
+        "sha256": "06063d21457e06dc2aba4a5bd09771147ec3d8ab40b224f26e55c5a76089ca43",
+        "strip_prefix": "github.com/kr/pretty@v0.1.0",
+        "version": "v0.1.0",
+    },
+    "com_github_kr_pty": {
+        "filename": "com_github_kr_pty__v1.1.8.zip",
+        "importpath": "github.com/kr/pty",
+        "sha256": "d66e6fbc65e772289a7ff8c58ab2cdfb886253053b0cea11ba3ca1738b2d6bc6",
+        "strip_prefix": "github.com/kr/pty@v1.1.8",
+        "version": "v1.1.8",
+    },
+    "com_github_kr_text": {
+        "filename": "com_github_kr_text__v0.1.0.zip",
+        "importpath": "github.com/kr/text",
+        "sha256": "9363a4c8f1f3387a36014de51b477b831a13981fc59a5665f9d21609bea9e77c",
+        "strip_prefix": "github.com/kr/text@v0.1.0",
+        "version": "v0.1.0",
     },
     "com_github_lib_pq": {
         "filename": "com_github_lib_pq__v1.10.2.zip",
@@ -182,6 +343,27 @@
         "strip_prefix": "github.com/lib/pq@v1.10.2",
         "version": "v1.10.2",
     },
+    "com_github_masterminds_semver_v3": {
+        "filename": "com_github_masterminds_semver_v3__v3.1.1.zip",
+        "importpath": "github.com/Masterminds/semver/v3",
+        "sha256": "0a46c7403dfeda09b0821e851f8e1cec8f1ea4276281e42ea399da5bc5bf0704",
+        "strip_prefix": "github.com/Masterminds/semver/v3@v3.1.1",
+        "version": "v3.1.1",
+    },
+    "com_github_mattn_go_colorable": {
+        "filename": "com_github_mattn_go_colorable__v0.1.6.zip",
+        "importpath": "github.com/mattn/go-colorable",
+        "sha256": "0da5d3779775f6fe5d007e7ec8e0afc136c4bd7b8c9b5cd73254db26773cf4dc",
+        "strip_prefix": "github.com/mattn/go-colorable@v0.1.6",
+        "version": "v0.1.6",
+    },
+    "com_github_mattn_go_isatty": {
+        "filename": "com_github_mattn_go_isatty__v0.0.12.zip",
+        "importpath": "github.com/mattn/go-isatty",
+        "sha256": "07941d24e0894c29dc42bcd29d644815cd7b5ee84e3c14bbe6d51ad13efcbf07",
+        "strip_prefix": "github.com/mattn/go-isatty@v0.0.12",
+        "version": "v0.0.12",
+    },
     "com_github_phst_runfiles": {
         "filename": "com_github_phst_runfiles__v0.0.0-20220125203201-388095b3a22d.zip",
         "importpath": "github.com/phst/runfiles",
@@ -217,6 +399,34 @@
         "strip_prefix": "github.com/rogpeppe/fastuuid@v1.2.0",
         "version": "v1.2.0",
     },
+    "com_github_rogpeppe_go_internal": {
+        "filename": "com_github_rogpeppe_go_internal__v1.3.0.zip",
+        "importpath": "github.com/rogpeppe/go-internal",
+        "sha256": "191b95c35d85a5683cee6e303a08b4d103bf9de9ececdc6904f21ed90c094b0a",
+        "strip_prefix": "github.com/rogpeppe/go-internal@v1.3.0",
+        "version": "v1.3.0",
+    },
+    "com_github_rs_xid": {
+        "filename": "com_github_rs_xid__v1.2.1.zip",
+        "importpath": "github.com/rs/xid",
+        "sha256": "4abdedc4de69adcb9a4575f99c59d8ab542191e1800b6a91e12a4e9ea8da0026",
+        "strip_prefix": "github.com/rs/xid@v1.2.1",
+        "version": "v1.2.1",
+    },
+    "com_github_rs_zerolog": {
+        "filename": "com_github_rs_zerolog__v1.15.0.zip",
+        "importpath": "github.com/rs/zerolog",
+        "sha256": "8e98c48e7fd132aafbf129664e8fd65229d067d772bff4bd712a497b7a2f00c4",
+        "strip_prefix": "github.com/rs/zerolog@v1.15.0",
+        "version": "v1.15.0",
+    },
+    "com_github_satori_go_uuid": {
+        "filename": "com_github_satori_go_uuid__v1.2.0.zip",
+        "importpath": "github.com/satori/go.uuid",
+        "sha256": "4f741306a0cbe97581e34a638531bcafe3c2848150539a2ec2ba12c5e3e6cbdd",
+        "strip_prefix": "github.com/satori/go.uuid@v1.2.0",
+        "version": "v1.2.0",
+    },
     "com_github_shopspring_decimal": {
         "filename": "com_github_shopspring_decimal__v1.2.0.zip",
         "importpath": "github.com/shopspring/decimal",
@@ -224,12 +434,19 @@
         "strip_prefix": "github.com/shopspring/decimal@v1.2.0",
         "version": "v1.2.0",
     },
+    "com_github_sirupsen_logrus": {
+        "filename": "com_github_sirupsen_logrus__v1.4.2.zip",
+        "importpath": "github.com/sirupsen/logrus",
+        "sha256": "9a8e55830261a4b1c9350d7c45db029c8586c0b2d934d1224cde469425031edd",
+        "strip_prefix": "github.com/sirupsen/logrus@v1.4.2",
+        "version": "v1.4.2",
+    },
     "com_github_stretchr_objx": {
-        "filename": "com_github_stretchr_objx__v0.1.0.zip",
+        "filename": "com_github_stretchr_objx__v0.2.0.zip",
         "importpath": "github.com/stretchr/objx",
-        "sha256": "1fa10dab404ed7fc8ed2a033f8784187d5df3513ced3841ce39e46d37850eb1d",
-        "strip_prefix": "github.com/stretchr/objx@v0.1.0",
-        "version": "v0.1.0",
+        "sha256": "5517d43cfb7e628b9c2c64010b934e346cd24726e3d6eaf02b7f86e10752e968",
+        "strip_prefix": "github.com/stretchr/objx@v0.2.0",
+        "version": "v0.2.0",
     },
     "com_github_stretchr_testify": {
         "filename": "com_github_stretchr_testify__v1.7.0.zip",
@@ -238,6 +455,13 @@
         "strip_prefix": "github.com/stretchr/testify@v1.7.0",
         "version": "v1.7.0",
     },
+    "com_github_zenazn_goji": {
+        "filename": "com_github_zenazn_goji__v0.9.0.zip",
+        "importpath": "github.com/zenazn/goji",
+        "sha256": "0807a255d9d715d18427a6eedd8e4f5a22670b09e5f45fddd229c1ae38da25a9",
+        "strip_prefix": "github.com/zenazn/goji@v0.9.0",
+        "version": "v0.9.0",
+    },
     "com_google_cloud_go": {
         "filename": "com_google_cloud_go__v0.34.0.zip",
         "importpath": "cloud.google.com/go",
@@ -246,11 +470,25 @@
         "version": "v0.34.0",
     },
     "in_gopkg_check_v1": {
-        "filename": "in_gopkg_check_v1__v0.0.0-20161208181325-20d25e280405.zip",
+        "filename": "in_gopkg_check_v1__v1.0.0-20180628173108-788fd7840127.zip",
         "importpath": "gopkg.in/check.v1",
-        "sha256": "4e1817f964ca34e545b81afda0325a5e89cf58de2e413d8207c0afddd0fdc15c",
-        "strip_prefix": "gopkg.in/check.v1@v0.0.0-20161208181325-20d25e280405",
-        "version": "v0.0.0-20161208181325-20d25e280405",
+        "sha256": "4bc535ed2aac48a231af8b6005a0b5f6069dadab9a3d65b1e9f1fe91c74d8e61",
+        "strip_prefix": "gopkg.in/check.v1@v1.0.0-20180628173108-788fd7840127",
+        "version": "v1.0.0-20180628173108-788fd7840127",
+    },
+    "in_gopkg_errgo_v2": {
+        "filename": "in_gopkg_errgo_v2__v2.1.0.zip",
+        "importpath": "gopkg.in/errgo.v2",
+        "sha256": "6b8954819a20ec52982a206fd3eb94629ff53c5790aa77534e6d8daf7de01bee",
+        "strip_prefix": "gopkg.in/errgo.v2@v2.1.0",
+        "version": "v2.1.0",
+    },
+    "in_gopkg_inconshreveable_log15_v2": {
+        "filename": "in_gopkg_inconshreveable_log15_v2__v2.0.0-20180818164646-67afb5ed74ec.zip",
+        "importpath": "gopkg.in/inconshreveable/log15.v2",
+        "sha256": "799307ed46ca30ca0ac2dc0332f3673814b8ff6cc1ee905a462ccfd438e8e695",
+        "strip_prefix": "gopkg.in/inconshreveable/log15.v2@v2.0.0-20180818164646-67afb5ed74ec",
+        "version": "v2.0.0-20180818164646-67afb5ed74ec",
     },
     "in_gopkg_yaml_v2": {
         "filename": "in_gopkg_yaml_v2__v2.2.3.zip",
@@ -266,6 +504,20 @@
         "strip_prefix": "gopkg.in/yaml.v3@v3.0.0-20200313102051-9f266ea9e77c",
         "version": "v3.0.0-20200313102051-9f266ea9e77c",
     },
+    "io_gorm_driver_postgres": {
+        "filename": "io_gorm_driver_postgres__v1.3.7.zip",
+        "importpath": "gorm.io/driver/postgres",
+        "sha256": "b38fed3060ea8ee200d50666a9c6230f2c387d4ab930b70dd859b93f5fac7771",
+        "strip_prefix": "gorm.io/driver/postgres@v1.3.7",
+        "version": "v1.3.7",
+    },
+    "io_gorm_gorm": {
+        "filename": "io_gorm_gorm__v1.23.5.zip",
+        "importpath": "gorm.io/gorm",
+        "sha256": "34219a6d2ac9b9c340f811e5863a98b150db6d1fd5b8f02777299863c1628e0f",
+        "strip_prefix": "gorm.io/gorm@v1.23.5",
+        "version": "v1.23.5",
+    },
     "io_opentelemetry_go_proto_otlp": {
         "filename": "io_opentelemetry_go_proto_otlp__v0.7.0.zip",
         "importpath": "go.opentelemetry.io/proto/otlp",
@@ -302,11 +554,11 @@
         "version": "v1.26.0",
     },
     "org_golang_x_crypto": {
-        "filename": "org_golang_x_crypto__v0.0.0-20210711020723-a769d52b0f97.zip",
+        "filename": "org_golang_x_crypto__v0.0.0-20210921155107-089bfa567519.zip",
         "importpath": "golang.org/x/crypto",
-        "sha256": "b2b28fcf49bf385183f0369851145ddd93989f68d9e675db536a3dd482ca6d76",
-        "strip_prefix": "golang.org/x/crypto@v0.0.0-20210711020723-a769d52b0f97",
-        "version": "v0.0.0-20210711020723-a769d52b0f97",
+        "sha256": "eb2426a7891915213cc5da1da7b6fc6e9e2cf253d518d8e169e038e287f414e3",
+        "strip_prefix": "golang.org/x/crypto@v0.0.0-20210921155107-089bfa567519",
+        "version": "v0.0.0-20210921155107-089bfa567519",
     },
     "org_golang_x_exp": {
         "filename": "org_golang_x_exp__v0.0.0-20190121172915-509febef88a4.zip",
@@ -316,11 +568,18 @@
         "version": "v0.0.0-20190121172915-509febef88a4",
     },
     "org_golang_x_lint": {
-        "filename": "org_golang_x_lint__v0.0.0-20190313153728-d0100b6bd8b3.zip",
+        "filename": "org_golang_x_lint__v0.0.0-20190930215403-16217165b5de.zip",
         "importpath": "golang.org/x/lint",
-        "sha256": "5c7bb9792bdc4ec4cf1af525cf9998f8a958daf6495852c9a7dbb71738f2f10a",
-        "strip_prefix": "golang.org/x/lint@v0.0.0-20190313153728-d0100b6bd8b3",
-        "version": "v0.0.0-20190313153728-d0100b6bd8b3",
+        "sha256": "91323fe1a77f13de722a0ce8efc5c5f2da4f26216d858acec64cb23c956fa163",
+        "strip_prefix": "golang.org/x/lint@v0.0.0-20190930215403-16217165b5de",
+        "version": "v0.0.0-20190930215403-16217165b5de",
+    },
+    "org_golang_x_mod": {
+        "filename": "org_golang_x_mod__v0.1.1-0.20191105210325-c90efee705ee.zip",
+        "importpath": "golang.org/x/mod",
+        "sha256": "b1e6cb975c69d29974b4f77fd8a0f2f7e916a1fa971bab60fdd45ffe80a29f32",
+        "strip_prefix": "golang.org/x/mod@v0.1.1-0.20191105210325-c90efee705ee",
+        "version": "v0.1.1-0.20191105210325-c90efee705ee",
     },
     "org_golang_x_net": {
         "filename": "org_golang_x_net__v0.0.0-20210226172049-e18ecbb05110.zip",
@@ -358,18 +617,18 @@
         "version": "v0.0.0-20201126162022-7de9c90e9dd1",
     },
     "org_golang_x_text": {
-        "filename": "org_golang_x_text__v0.3.6.zip",
+        "filename": "org_golang_x_text__v0.3.7.zip",
         "importpath": "golang.org/x/text",
-        "sha256": "2afade648a4cb240afb7b3bf8e3719b615169c90d6281bd6d4ba34629c744579",
-        "strip_prefix": "golang.org/x/text@v0.3.6",
-        "version": "v0.3.6",
+        "sha256": "e1a9115e61a38da8bdc893d0ba83b65f89cc1114f152a98eb572c5ea6551e8d4",
+        "strip_prefix": "golang.org/x/text@v0.3.7",
+        "version": "v0.3.7",
     },
     "org_golang_x_tools": {
-        "filename": "org_golang_x_tools__v0.0.0-20190524140312-2c0ae7006135.zip",
+        "filename": "org_golang_x_tools__v0.0.0-20200103221440-774c71fcf114.zip",
         "importpath": "golang.org/x/tools",
-        "sha256": "86687e8cd5adccf8809ba031e59146d0c89047b6267aacc785ffc20b0ce6b735",
-        "strip_prefix": "golang.org/x/tools@v0.0.0-20190524140312-2c0ae7006135",
-        "version": "v0.0.0-20190524140312-2c0ae7006135",
+        "sha256": "1c26b6b98d945255dfb6112d71135de3919350250e44e552a7089f724d0b7bfc",
+        "strip_prefix": "golang.org/x/tools@v0.0.0-20200103221440-774c71fcf114",
+        "version": "v0.0.0-20200103221440-774c71fcf114",
     },
     "org_golang_x_xerrors": {
         "filename": "org_golang_x_xerrors__v0.0.0-20200804184101-5ec99f83aff1.zip",
@@ -378,4 +637,32 @@
         "strip_prefix": "golang.org/x/xerrors@v0.0.0-20200804184101-5ec99f83aff1",
         "version": "v0.0.0-20200804184101-5ec99f83aff1",
     },
+    "org_uber_go_atomic": {
+        "filename": "org_uber_go_atomic__v1.6.0.zip",
+        "importpath": "go.uber.org/atomic",
+        "sha256": "c5e1e9f48017d7c7f7bb4532235e33242a1508bee68abe3cd301b68fe8ecd552",
+        "strip_prefix": "go.uber.org/atomic@v1.6.0",
+        "version": "v1.6.0",
+    },
+    "org_uber_go_multierr": {
+        "filename": "org_uber_go_multierr__v1.5.0.zip",
+        "importpath": "go.uber.org/multierr",
+        "sha256": "64053b7f6129cf2588f9b9ef1e934a26a0381da0002add973ec99f1294c1fc1e",
+        "strip_prefix": "go.uber.org/multierr@v1.5.0",
+        "version": "v1.5.0",
+    },
+    "org_uber_go_tools": {
+        "filename": "org_uber_go_tools__v0.0.0-20190618225709-2cfd321de3ee.zip",
+        "importpath": "go.uber.org/tools",
+        "sha256": "988dba9c5074080240d33d98e8ce511532f728698db7a9a4ac316c02c94030d6",
+        "strip_prefix": "go.uber.org/tools@v0.0.0-20190618225709-2cfd321de3ee",
+        "version": "v0.0.0-20190618225709-2cfd321de3ee",
+    },
+    "org_uber_go_zap": {
+        "filename": "org_uber_go_zap__v1.13.0.zip",
+        "importpath": "go.uber.org/zap",
+        "sha256": "4b4d15be7b4ce8029ab7c90f2fcb4c98e655172ebaa5cdbe234401081000fa26",
+        "strip_prefix": "go.uber.org/zap@v1.13.0",
+        "version": "v1.13.0",
+    },
 }
diff --git a/tools/python/generate_pip_packages_in_docker.sh b/tools/python/generate_pip_packages_in_docker.sh
index a3af2ae..13d51b7 100755
--- a/tools/python/generate_pip_packages_in_docker.sh
+++ b/tools/python/generate_pip_packages_in_docker.sh
@@ -101,6 +101,17 @@
 "${PIP_BIN[@]}" install auditwheel
 for wheel in "${wheels_built_from_source[@]}"; do
   wheel_path="${SCRIPT_DIR}/wheelhouse_tmp/${wheel}"
+
+  # Skip the pygobject wheel for now. I have no idea why, but repairing it will
+  # prevent it from finding certain files. Possibly some issue with paths
+  # relative to the .so file.
+  # TODO(phil): Figure out what's wrong with the repaired wheel.
+  if [[ "${wheel}" == PyGObject-*.whl ]]; then
+    echo "Not repairing ${wheel} because of issues."
+    cp "${wheel_path}" "${SCRIPT_DIR}"/wheelhouse/
+    continue
+  fi
+
   echo "Repairing wheel ${wheel}"
   if ! auditwheel show "${wheel_path}"; then
     echo "Assuming ${wheel} is a non-platform wheel. Skipping."
diff --git a/tools/python/requirements.lock.txt b/tools/python/requirements.lock.txt
index a48b2a7..93ac065 100644
--- a/tools/python/requirements.lock.txt
+++ b/tools/python/requirements.lock.txt
@@ -4,9 +4,9 @@
 #
 #    bazel run //tools/python:requirements.update
 #
-certifi==2022.9.14 \
-    --hash=sha256:36973885b9542e6bd01dea287b2b4b3b21236307c56324fcc3f1160f2d655ed5 \
-    --hash=sha256:e232343de1ab72c2aa521b625c80f699e356830fd0e2c620b465b304b17b0516
+certifi==2022.9.24 \
+    --hash=sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14 \
+    --hash=sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382
     # via requests
 charset-normalizer==2.1.1 \
     --hash=sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845 \
@@ -16,13 +16,84 @@
     --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \
     --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48
     # via mkdocs
+contourpy==1.0.6 \
+    --hash=sha256:0236875c5a0784215b49d00ebbe80c5b6b5d5244b3655a36dda88105334dea17 \
+    --hash=sha256:03d1b9c6b44a9e30d554654c72be89af94fab7510b4b9f62356c64c81cec8b7d \
+    --hash=sha256:0537cc1195245bbe24f2913d1f9211b8f04eb203de9044630abd3664c6cc339c \
+    --hash=sha256:06ca79e1efbbe2df795822df2fa173d1a2b38b6e0f047a0ec7903fbca1d1847e \
+    --hash=sha256:08e8d09d96219ace6cb596506fb9b64ea5f270b2fb9121158b976d88871fcfd1 \
+    --hash=sha256:0b1e66346acfb17694d46175a0cea7d9036f12ed0c31dfe86f0f405eedde2bdd \
+    --hash=sha256:0b97454ed5b1368b66ed414c754cba15b9750ce69938fc6153679787402e4cdf \
+    --hash=sha256:0e4854cc02006ad6684ce092bdadab6f0912d131f91c2450ce6dbdea78ee3c0b \
+    --hash=sha256:12a7dc8439544ed05c6553bf026d5e8fa7fad48d63958a95d61698df0e00092b \
+    --hash=sha256:1b1ee48a130da4dd0eb8055bbab34abf3f6262957832fd575e0cab4979a15a41 \
+    --hash=sha256:1c0e1308307a75e07d1f1b5f0f56b5af84538a5e9027109a7bcf6cb47c434e72 \
+    --hash=sha256:1dedf4c64185a216c35eb488e6f433297c660321275734401760dafaeb0ad5c2 \
+    --hash=sha256:208bc904889c910d95aafcf7be9e677726df9ef71e216780170dbb7e37d118fa \
+    --hash=sha256:211dfe2bd43bf5791d23afbe23a7952e8ac8b67591d24be3638cabb648b3a6eb \
+    --hash=sha256:341330ed19074f956cb20877ad8d2ae50e458884bfa6a6df3ae28487cc76c768 \
+    --hash=sha256:344cb3badf6fc7316ad51835f56ac387bdf86c8e1b670904f18f437d70da4183 \
+    --hash=sha256:358f6364e4873f4d73360b35da30066f40387dd3c427a3e5432c6b28dd24a8fa \
+    --hash=sha256:371f6570a81dfdddbb837ba432293a63b4babb942a9eb7aaa699997adfb53278 \
+    --hash=sha256:375d81366afd547b8558c4720337218345148bc2fcffa3a9870cab82b29667f2 \
+    --hash=sha256:3a1917d3941dd58732c449c810fa7ce46cc305ce9325a11261d740118b85e6f3 \
+    --hash=sha256:4081918147fc4c29fad328d5066cfc751da100a1098398742f9f364be63803fc \
+    --hash=sha256:444fb776f58f4906d8d354eb6f6ce59d0a60f7b6a720da6c1ccb839db7c80eb9 \
+    --hash=sha256:46deb310a276cc5c1fd27958e358cce68b1e8a515fa5a574c670a504c3a3fe30 \
+    --hash=sha256:494efed2c761f0f37262815f9e3c4bb9917c5c69806abdee1d1cb6611a7174a0 \
+    --hash=sha256:50627bf76abb6ba291ad08db583161939c2c5fab38c38181b7833423ab9c7de3 \
+    --hash=sha256:5641927cc5ae66155d0c80195dc35726eae060e7defc18b7ab27600f39dd1fe7 \
+    --hash=sha256:5b117d29433fc8393b18a696d794961464e37afb34a6eeb8b2c37b5f4128a83e \
+    --hash=sha256:613c665529899b5d9fade7e5d1760111a0b011231277a0d36c49f0d3d6914bd6 \
+    --hash=sha256:6e459ebb8bb5ee4c22c19cc000174f8059981971a33ce11e17dddf6aca97a142 \
+    --hash=sha256:6f56515e7c6fae4529b731f6c117752247bef9cdad2b12fc5ddf8ca6a50965a5 \
+    --hash=sha256:730c27978a0003b47b359935478b7d63fd8386dbb2dcd36c1e8de88cbfc1e9de \
+    --hash=sha256:75a2e638042118118ab39d337da4c7908c1af74a8464cad59f19fbc5bbafec9b \
+    --hash=sha256:78ced51807ccb2f45d4ea73aca339756d75d021069604c2fccd05390dc3c28eb \
+    --hash=sha256:7ee394502026d68652c2824348a40bf50f31351a668977b51437131a90d777ea \
+    --hash=sha256:8468b40528fa1e15181cccec4198623b55dcd58306f8815a793803f51f6c474a \
+    --hash=sha256:84c593aeff7a0171f639da92cb86d24954bbb61f8a1b530f74eb750a14685832 \
+    --hash=sha256:913bac9d064cff033cf3719e855d4f1db9f1c179e0ecf3ba9fdef21c21c6a16a \
+    --hash=sha256:9447c45df407d3ecb717d837af3b70cfef432138530712263730783b3d016512 \
+    --hash=sha256:9b0e7fe7f949fb719b206548e5cde2518ffb29936afa4303d8a1c4db43dcb675 \
+    --hash=sha256:9bc407a6af672da20da74823443707e38ece8b93a04009dca25856c2d9adadb1 \
+    --hash=sha256:9e8e686a6db92a46111a1ee0ee6f7fbfae4048f0019de207149f43ac1812cf95 \
+    --hash=sha256:9fc4e7973ed0e1fe689435842a6e6b330eb7ccc696080dda9a97b1a1b78e41db \
+    --hash=sha256:a457ee72d9032e86730f62c5eeddf402e732fdf5ca8b13b41772aa8ae13a4563 \
+    --hash=sha256:a628bba09ba72e472bf7b31018b6281fd4cc903f0888049a3724afba13b6e0b8 \
+    --hash=sha256:a79d239fc22c3b8d9d3de492aa0c245533f4f4c7608e5749af866949c0f1b1b9 \
+    --hash=sha256:aa4674cf3fa2bd9c322982644967f01eed0c91bb890f624e0e0daf7a5c3383e9 \
+    --hash=sha256:acd2bd02f1a7adff3a1f33e431eb96ab6d7987b039d2946a9b39fe6fb16a1036 \
+    --hash=sha256:b3b1bd7577c530eaf9d2bc52d1a93fef50ac516a8b1062c3d1b9bcec9ebe329b \
+    --hash=sha256:b48d94386f1994db7c70c76b5808c12e23ed7a4ee13693c2fc5ab109d60243c0 \
+    --hash=sha256:b64f747e92af7da3b85631a55d68c45a2d728b4036b03cdaba4bd94bcc85bd6f \
+    --hash=sha256:b98c820608e2dca6442e786817f646d11057c09a23b68d2b3737e6dcb6e4a49b \
+    --hash=sha256:c1baa49ab9fedbf19d40d93163b7d3e735d9cd8d5efe4cce9907902a6dad391f \
+    --hash=sha256:c38c6536c2d71ca2f7e418acaf5bca30a3af7f2a2fa106083c7d738337848dbe \
+    --hash=sha256:c78bfbc1a7bff053baf7e508449d2765964d67735c909b583204e3240a2aca45 \
+    --hash=sha256:cd2bc0c8f2e8de7dd89a7f1c10b8844e291bca17d359373203ef2e6100819edd \
+    --hash=sha256:d2eff2af97ea0b61381828b1ad6cd249bbd41d280e53aea5cccd7b2b31b8225c \
+    --hash=sha256:d8834c14b8c3dd849005e06703469db9bf96ba2d66a3f88ecc539c9a8982e0ee \
+    --hash=sha256:d912f0154a20a80ea449daada904a7eb6941c83281a9fab95de50529bfc3a1da \
+    --hash=sha256:da1ef35fd79be2926ba80fbb36327463e3656c02526e9b5b4c2b366588b74d9a \
+    --hash=sha256:dbe6fe7a1166b1ddd7b6d887ea6fa8389d3f28b5ed3f73a8f40ece1fc5a3d340 \
+    --hash=sha256:dcd556c8fc37a342dd636d7eef150b1399f823a4462f8c968e11e1ebeabee769 \
+    --hash=sha256:e13b31d1b4b68db60b3b29f8e337908f328c7f05b9add4b1b5c74e0691180109 \
+    --hash=sha256:e1739496c2f0108013629aa095cc32a8c6363444361960c07493818d0dea2da4 \
+    --hash=sha256:e43255a83835a129ef98f75d13d643844d8c646b258bebd11e4a0975203e018f \
+    --hash=sha256:e626cefff8491bce356221c22af5a3ea528b0b41fbabc719c00ae233819ea0bf \
+    --hash=sha256:eadad75bf91897f922e0fb3dca1b322a58b1726a953f98c2e5f0606bd8408621 \
+    --hash=sha256:f33da6b5d19ad1bb5e7ad38bb8ba5c426d2178928bc2b2c44e8823ea0ecb6ff3 \
+    --hash=sha256:f4052a8a4926d4468416fc7d4b2a7b2a3e35f25b39f4061a7e2a3a2748c4fc48 \
+    --hash=sha256:f6ca38dd8d988eca8f07305125dec6f54ac1c518f1aaddcc14d08c01aebb6efc
+    # via matplotlib
 cycler==0.11.0 \
     --hash=sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3 \
     --hash=sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f
     # via matplotlib
-fonttools==4.28.5 \
-    --hash=sha256:545c05d0f7903a863c2020e07b8f0a57517f2c40d940bded77076397872d14ca \
-    --hash=sha256:edf251d5d2cc0580d5f72de4621c338d8c66c5f61abb50cf486640f73c8194d5
+fonttools==4.38.0 \
+    --hash=sha256:2bb244009f9bf3fa100fc3ead6aeb99febe5985fa20afbfbaa2f8946c2fbdaf1 \
+    --hash=sha256:820466f43c8be8c3009aef8b87e785014133508f0de64ec469e4efb643ae54fb
     # via matplotlib
 ghp-import==2.1.0 \
     --hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619 \
@@ -32,9 +103,9 @@
     --hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \
     --hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2
     # via requests
-importlib-metadata==5.0.0 \
-    --hash=sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab \
-    --hash=sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43
+importlib-metadata==5.1.0 \
+    --hash=sha256:d5059f9f1e8e41f80e9c56c2ee58811450c31984dfa625329ffd7c0dad88a73b \
+    --hash=sha256:d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313
     # via
     #   markdown
     #   mkdocs
@@ -44,51 +115,75 @@
     # via
     #   -r tools/python/requirements.txt
     #   mkdocs
-kiwisolver==1.3.2 \
-    --hash=sha256:0007840186bacfaa0aba4466d5890334ea5938e0bb7e28078a0eb0e63b5b59d5 \
-    --hash=sha256:19554bd8d54cf41139f376753af1a644b63c9ca93f8f72009d50a2080f870f77 \
-    --hash=sha256:1d45d1c74f88b9f41062716c727f78f2a59a5476ecbe74956fafb423c5c87a76 \
-    --hash=sha256:1d819553730d3c2724582124aee8a03c846ec4362ded1034c16fb3ef309264e6 \
-    --hash=sha256:2210f28778c7d2ee13f3c2a20a3a22db889e75f4ec13a21072eabb5693801e84 \
-    --hash=sha256:22521219ca739654a296eea6d4367703558fba16f98688bd8ce65abff36eaa84 \
-    --hash=sha256:25405f88a37c5f5bcba01c6e350086d65e7465fd1caaf986333d2a045045a223 \
-    --hash=sha256:2b65bd35f3e06a47b5c30ea99e0c2b88f72c6476eedaf8cfbc8e66adb5479dcf \
-    --hash=sha256:2ddb500a2808c100e72c075cbb00bf32e62763c82b6a882d403f01a119e3f402 \
-    --hash=sha256:2f8f6c8f4f1cff93ca5058d6ec5f0efda922ecb3f4c5fb76181f327decff98b8 \
-    --hash=sha256:30fa008c172355c7768159983a7270cb23838c4d7db73d6c0f6b60dde0d432c6 \
-    --hash=sha256:3dbb3cea20b4af4f49f84cffaf45dd5f88e8594d18568e0225e6ad9dec0e7967 \
-    --hash=sha256:4116ba9a58109ed5e4cb315bdcbff9838f3159d099ba5259c7c7fb77f8537492 \
-    --hash=sha256:44e6adf67577dbdfa2d9f06db9fbc5639afefdb5bf2b4dfec25c3a7fbc619536 \
-    --hash=sha256:5326ddfacbe51abf9469fe668944bc2e399181a2158cb5d45e1d40856b2a0589 \
-    --hash=sha256:70adc3658138bc77a36ce769f5f183169bc0a2906a4f61f09673f7181255ac9b \
-    --hash=sha256:72be6ebb4e92520b9726d7146bc9c9b277513a57a38efcf66db0620aec0097e0 \
-    --hash=sha256:7843b1624d6ccca403a610d1277f7c28ad184c5aa88a1750c1a999754e65b439 \
-    --hash=sha256:7ba5a1041480c6e0a8b11a9544d53562abc2d19220bfa14133e0cdd9967e97af \
-    --hash=sha256:80efd202108c3a4150e042b269f7c78643420cc232a0a771743bb96b742f838f \
-    --hash=sha256:82f49c5a79d3839bc8f38cb5f4bfc87e15f04cbafa5fbd12fb32c941cb529cfb \
-    --hash=sha256:83d2c9db5dfc537d0171e32de160461230eb14663299b7e6d18ca6dca21e4977 \
-    --hash=sha256:8d93a1095f83e908fc253f2fb569c2711414c0bfd451cab580466465b235b470 \
-    --hash=sha256:8dc3d842fa41a33fe83d9f5c66c0cc1f28756530cd89944b63b072281e852031 \
-    --hash=sha256:9661a04ca3c950a8ac8c47f53cbc0b530bce1b52f516a1e87b7736fec24bfff0 \
-    --hash=sha256:a498bcd005e8a3fedd0022bb30ee0ad92728154a8798b703f394484452550507 \
-    --hash=sha256:a7a4cf5bbdc861987a7745aed7a536c6405256853c94abc9f3287c3fa401b174 \
-    --hash=sha256:b5074fb09429f2b7bc82b6fb4be8645dcbac14e592128beeff5461dcde0af09f \
-    --hash=sha256:b6a5431940f28b6de123de42f0eb47b84a073ee3c3345dc109ad550a3307dd28 \
-    --hash=sha256:ba677bcaff9429fd1bf01648ad0901cea56c0d068df383d5f5856d88221fe75b \
-    --hash=sha256:bcadb05c3d4794eb9eee1dddf1c24215c92fb7b55a80beae7a60530a91060560 \
-    --hash=sha256:bf7eb45d14fc036514c09554bf983f2a72323254912ed0c3c8e697b62c4c158f \
-    --hash=sha256:c358721aebd40c243894298f685a19eb0491a5c3e0b923b9f887ef1193ddf829 \
-    --hash=sha256:c4550a359c5157aaf8507e6820d98682872b9100ce7607f8aa070b4b8af6c298 \
-    --hash=sha256:c6572c2dab23c86a14e82c245473d45b4c515314f1f859e92608dcafbd2f19b8 \
-    --hash=sha256:cba430db673c29376135e695c6e2501c44c256a81495da849e85d1793ee975ad \
-    --hash=sha256:dedc71c8eb9c5096037766390172c34fb86ef048b8e8958b4e484b9e505d66bc \
-    --hash=sha256:e6f5eb2f53fac7d408a45fbcdeda7224b1cfff64919d0f95473420a931347ae9 \
-    --hash=sha256:ec2eba188c1906b05b9b49ae55aae4efd8150c61ba450e6721f64620c50b59eb \
-    --hash=sha256:ee040a7de8d295dbd261ef2d6d3192f13e2b08ec4a954de34a6fb8ff6422e24c \
-    --hash=sha256:eedd3b59190885d1ebdf6c5e0ca56828beb1949b4dfe6e5d0256a461429ac386 \
-    --hash=sha256:f441422bb313ab25de7b3dbfd388e790eceb76ce01a18199ec4944b369017009 \
-    --hash=sha256:f8eb7b6716f5b50e9c06207a14172cf2de201e41912ebe732846c02c830455b9 \
-    --hash=sha256:fc4453705b81d03568d5b808ad8f09c77c47534f6ac2e72e733f9ca4714aa75c
+kiwisolver==1.4.4 \
+    --hash=sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b \
+    --hash=sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166 \
+    --hash=sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c \
+    --hash=sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c \
+    --hash=sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0 \
+    --hash=sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4 \
+    --hash=sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9 \
+    --hash=sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286 \
+    --hash=sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767 \
+    --hash=sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c \
+    --hash=sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6 \
+    --hash=sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b \
+    --hash=sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004 \
+    --hash=sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf \
+    --hash=sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494 \
+    --hash=sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac \
+    --hash=sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626 \
+    --hash=sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766 \
+    --hash=sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514 \
+    --hash=sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6 \
+    --hash=sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f \
+    --hash=sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d \
+    --hash=sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191 \
+    --hash=sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d \
+    --hash=sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51 \
+    --hash=sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f \
+    --hash=sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8 \
+    --hash=sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454 \
+    --hash=sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb \
+    --hash=sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da \
+    --hash=sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8 \
+    --hash=sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de \
+    --hash=sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a \
+    --hash=sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9 \
+    --hash=sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008 \
+    --hash=sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3 \
+    --hash=sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32 \
+    --hash=sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938 \
+    --hash=sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1 \
+    --hash=sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9 \
+    --hash=sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d \
+    --hash=sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824 \
+    --hash=sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b \
+    --hash=sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd \
+    --hash=sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2 \
+    --hash=sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5 \
+    --hash=sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69 \
+    --hash=sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3 \
+    --hash=sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae \
+    --hash=sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597 \
+    --hash=sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e \
+    --hash=sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955 \
+    --hash=sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca \
+    --hash=sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a \
+    --hash=sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea \
+    --hash=sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede \
+    --hash=sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4 \
+    --hash=sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6 \
+    --hash=sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686 \
+    --hash=sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408 \
+    --hash=sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871 \
+    --hash=sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29 \
+    --hash=sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750 \
+    --hash=sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897 \
+    --hash=sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0 \
+    --hash=sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2 \
+    --hash=sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09 \
+    --hash=sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c
     # via matplotlib
 markdown==3.3.7 \
     --hash=sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874 \
@@ -136,84 +231,89 @@
     --hash=sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a \
     --hash=sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7
     # via jinja2
-matplotlib==3.5.1 \
-    --hash=sha256:14334b9902ec776461c4b8c6516e26b450f7ebe0b3ef8703bf5cdfbbaecf774a \
-    --hash=sha256:2252bfac85cec7af4a67e494bfccf9080bcba8a0299701eab075f48847cca907 \
-    --hash=sha256:2e3484d8455af3fdb0424eae1789af61f6a79da0c80079125112fd5c1b604218 \
-    --hash=sha256:34a1fc29f8f96e78ec57a5eff5e8d8b53d3298c3be6df61e7aa9efba26929522 \
-    --hash=sha256:3e66497cd990b1a130e21919b004da2f1dc112132c01ac78011a90a0f9229778 \
-    --hash=sha256:40e0d7df05e8efe60397c69b467fc8f87a2affeb4d562fe92b72ff8937a2b511 \
-    --hash=sha256:456cc8334f6d1124e8ff856b42d2cc1c84335375a16448189999496549f7182b \
-    --hash=sha256:506b210cc6e66a0d1c2bb765d055f4f6bc2745070fb1129203b67e85bbfa5c18 \
-    --hash=sha256:53273c5487d1c19c3bc03b9eb82adaf8456f243b97ed79d09dded747abaf1235 \
-    --hash=sha256:577ed20ec9a18d6bdedb4616f5e9e957b4c08563a9f985563a31fd5b10564d2a \
-    --hash=sha256:6803299cbf4665eca14428d9e886de62e24f4223ac31ab9c5d6d5339a39782c7 \
-    --hash=sha256:68fa30cec89b6139dc559ed6ef226c53fd80396da1919a1b5ef672c911aaa767 \
-    --hash=sha256:6c094e4bfecd2fa7f9adffd03d8abceed7157c928c2976899de282f3600f0a3d \
-    --hash=sha256:778d398c4866d8e36ee3bf833779c940b5f57192fa0a549b3ad67bc4c822771b \
-    --hash=sha256:7a350ca685d9f594123f652ba796ee37219bf72c8e0fc4b471473d87121d6d34 \
-    --hash=sha256:87900c67c0f1728e6db17c6809ec05c025c6624dcf96a8020326ea15378fe8e7 \
-    --hash=sha256:8a77906dc2ef9b67407cec0bdbf08e3971141e535db888974a915be5e1e3efc6 \
-    --hash=sha256:8e70ae6475cfd0fad3816dcbf6cac536dc6f100f7474be58d59fa306e6e768a4 \
-    --hash=sha256:abf67e05a1b7f86583f6ebd01f69b693b9c535276f4e943292e444855870a1b8 \
-    --hash=sha256:b04fc29bcef04d4e2d626af28d9d892be6aba94856cb46ed52bcb219ceac8943 \
-    --hash=sha256:b19a761b948e939a9e20173aaae76070025f0024fc8f7ba08bef22a5c8573afc \
-    --hash=sha256:b2e9810e09c3a47b73ce9cab5a72243a1258f61e7900969097a817232246ce1c \
-    --hash=sha256:b71f3a7ca935fc759f2aed7cec06cfe10bc3100fadb5dbd9c435b04e557971e1 \
-    --hash=sha256:b8a4fb2a0c5afbe9604f8a91d7d0f27b1832c3e0b5e365f95a13015822b4cd65 \
-    --hash=sha256:bb1c613908f11bac270bc7494d68b1ef6e7c224b7a4204d5dacf3522a41e2bc3 \
-    --hash=sha256:d24e5bb8028541ce25e59390122f5e48c8506b7e35587e5135efcb6471b4ac6c \
-    --hash=sha256:d70a32ee1f8b55eed3fd4e892f0286df8cccc7e0475c11d33b5d0a148f5c7599 \
-    --hash=sha256:e293b16cf303fe82995e41700d172a58a15efc5331125d08246b520843ef21ee \
-    --hash=sha256:e2f28a07b4f82abb40267864ad7b3a4ed76f1b1663e81c7efc84a9b9248f672f \
-    --hash=sha256:e3520a274a0e054e919f5b3279ee5dbccf5311833819ccf3399dab7c83e90a25 \
-    --hash=sha256:e3b6f3fd0d8ca37861c31e9a7cab71a0ef14c639b4c95654ea1dd153158bf0df \
-    --hash=sha256:e486f60db0cd1c8d68464d9484fd2a94011c1ac8593d765d0211f9daba2bd535 \
-    --hash=sha256:e8c87cdaf06fd7b2477f68909838ff4176f105064a72ca9d24d3f2a29f73d393 \
-    --hash=sha256:edf5e4e1d5fb22c18820e8586fb867455de3b109c309cb4fce3aaed85d9468d1 \
-    --hash=sha256:fe8d40c434a8e2c68d64c6d6a04e77f21791a93ff6afe0dce169597c110d3079
+matplotlib==3.6.2 \
+    --hash=sha256:0844523dfaaff566e39dbfa74e6f6dc42e92f7a365ce80929c5030b84caa563a \
+    --hash=sha256:0eda9d1b43f265da91fb9ae10d6922b5a986e2234470a524e6b18f14095b20d2 \
+    --hash=sha256:168093410b99f647ba61361b208f7b0d64dde1172b5b1796d765cd243cadb501 \
+    --hash=sha256:1836f366272b1557a613f8265db220eb8dd883202bbbabe01bad5a4eadfd0c95 \
+    --hash=sha256:19d61ee6414c44a04addbe33005ab1f87539d9f395e25afcbe9a3c50ce77c65c \
+    --hash=sha256:252957e208c23db72ca9918cb33e160c7833faebf295aaedb43f5b083832a267 \
+    --hash=sha256:32d29c8c26362169c80c5718ce367e8c64f4dd068a424e7110df1dd2ed7bd428 \
+    --hash=sha256:380d48c15ec41102a2b70858ab1dedfa33eb77b2c0982cb65a200ae67a48e9cb \
+    --hash=sha256:3964934731fd7a289a91d315919cf757f293969a4244941ab10513d2351b4e83 \
+    --hash=sha256:3cef89888a466228fc4e4b2954e740ce8e9afde7c4315fdd18caa1b8de58ca17 \
+    --hash=sha256:4426c74761790bff46e3d906c14c7aab727543293eed5a924300a952e1a3a3c1 \
+    --hash=sha256:5024b8ed83d7f8809982d095d8ab0b179bebc07616a9713f86d30cf4944acb73 \
+    --hash=sha256:52c2bdd7cd0bf9d5ccdf9c1816568fd4ccd51a4d82419cc5480f548981b47dd0 \
+    --hash=sha256:54fa9fe27f5466b86126ff38123261188bed568c1019e4716af01f97a12fe812 \
+    --hash=sha256:5ba73aa3aca35d2981e0b31230d58abb7b5d7ca104e543ae49709208d8ce706a \
+    --hash=sha256:5e16dcaecffd55b955aa5e2b8a804379789c15987e8ebd2f32f01398a81e975b \
+    --hash=sha256:5ecfc6559132116dedfc482d0ad9df8a89dc5909eebffd22f3deb684132d002f \
+    --hash=sha256:74153008bd24366cf099d1f1e83808d179d618c4e32edb0d489d526523a94d9f \
+    --hash=sha256:78ec3c3412cf277e6252764ee4acbdbec6920cc87ad65862272aaa0e24381eee \
+    --hash=sha256:795ad83940732b45d39b82571f87af0081c120feff2b12e748d96bb191169e33 \
+    --hash=sha256:7f716b6af94dc1b6b97c46401774472f0867e44595990fe80a8ba390f7a0a028 \
+    --hash=sha256:83dc89c5fd728fdb03b76f122f43b4dcee8c61f1489e232d9ad0f58020523e1c \
+    --hash=sha256:8a0ae37576ed444fe853709bdceb2be4c7df6f7acae17b8378765bd28e61b3ae \
+    --hash=sha256:8a8dbe2cb7f33ff54b16bb5c500673502a35f18ac1ed48625e997d40c922f9cc \
+    --hash=sha256:8a9d899953c722b9afd7e88dbefd8fb276c686c3116a43c577cfabf636180558 \
+    --hash=sha256:8d0068e40837c1d0df6e3abf1cdc9a34a6d2611d90e29610fa1d2455aeb4e2e5 \
+    --hash=sha256:9347cc6822f38db2b1d1ce992f375289670e595a2d1c15961aacbe0977407dfc \
+    --hash=sha256:9f335e5625feb90e323d7e3868ec337f7b9ad88b5d633f876e3b778813021dab \
+    --hash=sha256:b03fd10a1709d0101c054883b550f7c4c5e974f751e2680318759af005964990 \
+    --hash=sha256:b0ca2c60d3966dfd6608f5f8c49b8a0fcf76de6654f2eda55fc6ef038d5a6f27 \
+    --hash=sha256:b2604c6450f9dd2c42e223b1f5dca9643a23cfecc9fde4a94bb38e0d2693b136 \
+    --hash=sha256:ca0e7a658fbafcddcaefaa07ba8dae9384be2343468a8e011061791588d839fa \
+    --hash=sha256:d0e9ac04065a814d4cf2c6791a2ad563f739ae3ae830d716d54245c2b96fead6 \
+    --hash=sha256:d50e8c1e571ee39b5dfbc295c11ad65988879f68009dd281a6e1edbc2ff6c18c \
+    --hash=sha256:d840adcad7354be6f2ec28d0706528b0026e4c3934cc6566b84eac18633eab1b \
+    --hash=sha256:e0bbee6c2a5bf2a0017a9b5e397babb88f230e6f07c3cdff4a4c4bc75ed7c617 \
+    --hash=sha256:e5afe0a7ea0e3a7a257907060bee6724a6002b7eec55d0db16fd32409795f3e1 \
+    --hash=sha256:e68be81cd8c22b029924b6d0ee814c337c0e706b8d88495a617319e5dd5441c3 \
+    --hash=sha256:ec9be0f4826cdb3a3a517509dcc5f87f370251b76362051ab59e42b6b765f8c4 \
+    --hash=sha256:f04f97797df35e442ed09f529ad1235d1f1c0f30878e2fe09a2676b71a8801e0 \
+    --hash=sha256:f41e57ad63d336fe50d3a67bb8eaa26c09f6dda6a59f76777a99b8ccd8e26aec
     # via -r tools/python/requirements.txt
 mergedeep==1.3.4 \
     --hash=sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8 \
     --hash=sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307
     # via mkdocs
-mkdocs==1.4.0 \
-    --hash=sha256:ce057e9992f017b8e1496b591b6c242cbd34c2d406e2f9af6a19b97dd6248faa \
-    --hash=sha256:e5549a22d59e7cb230d6a791edd2c3d06690908454c0af82edc31b35d57e3069
+mkdocs==1.4.2 \
+    --hash=sha256:8947af423a6d0facf41ea1195b8e1e8c85ad94ac95ae307fe11232e0424b11c5 \
+    --hash=sha256:c8856a832c1e56702577023cd64cc5f84948280c1c0fcc6af4cd39006ea6aa8c
     # via -r tools/python/requirements.txt
-numpy==1.21.5 \
-    --hash=sha256:00c9fa73a6989895b8815d98300a20ac993c49ac36c8277e8ffeaa3631c0dbbb \
-    --hash=sha256:025b497014bc33fc23897859350f284323f32a2fff7654697f5a5fc2a19e9939 \
-    --hash=sha256:08de8472d9f7571f9d51b27b75e827f5296295fa78817032e84464be8bb905bc \
-    --hash=sha256:1964db2d4a00348b7a60ee9d013c8cb0c566644a589eaa80995126eac3b99ced \
-    --hash=sha256:2a9add27d7fc0fdb572abc3b2486eb3b1395da71e0254c5552b2aad2a18b5441 \
-    --hash=sha256:2d8adfca843bc46ac199a4645233f13abf2011a0b2f4affc5c37cd552626f27b \
-    --hash=sha256:301e408a052fdcda5cdcf03021ebafc3c6ea093021bf9d1aa47c54d48bdad166 \
-    --hash=sha256:311283acf880cfcc20369201bd75da907909afc4666966c7895cbed6f9d2c640 \
-    --hash=sha256:341dddcfe3b7b6427a28a27baa59af5ad51baa59bfec3264f1ab287aa3b30b13 \
-    --hash=sha256:3a5098df115340fb17fc93867317a947e1dcd978c3888c5ddb118366095851f8 \
-    --hash=sha256:3c978544be9e04ed12016dd295a74283773149b48f507d69b36f91aa90a643e5 \
-    --hash=sha256:3d893b0871322eaa2f8c7072cdb552d8e2b27645b7875a70833c31e9274d4611 \
-    --hash=sha256:4fe6a006557b87b352c04596a6e3f12a57d6e5f401d804947bd3188e6b0e0e76 \
-    --hash=sha256:507c05c7a37b3683eb08a3ff993bd1ee1e6c752f77c2f275260533b265ecdb6c \
-    --hash=sha256:58ca1d7c8aef6e996112d0ce873ac9dfa1eaf4a1196b4ff7ff73880a09923ba7 \
-    --hash=sha256:61bada43d494515d5b122f4532af226fdb5ee08fe5b5918b111279843dc6836a \
-    --hash=sha256:69a5a8d71c308d7ef33ef72371c2388a90e3495dbb7993430e674006f94797d5 \
-    --hash=sha256:6a5928bc6241264dce5ed509e66f33676fc97f464e7a919edc672fb5532221ee \
-    --hash=sha256:7b9d6b14fc9a4864b08d1ba57d732b248f0e482c7b2ff55c313137e3ed4d8449 \
-    --hash=sha256:a7c4b701ca418cd39e28ec3b496e6388fe06de83f5f0cb74794fa31cfa384c02 \
-    --hash=sha256:a7e8f6216f180f3fd4efb73de5d1eaefb5f5a1ee5b645c67333033e39440e63a \
-    --hash=sha256:b545ebadaa2b878c8630e5bcdb97fc4096e779f335fc0f943547c1c91540c815 \
-    --hash=sha256:c293d3c0321996cd8ffe84215ffe5d269fd9d1d12c6f4ffe2b597a7c30d3e593 \
-    --hash=sha256:c5562bcc1a9b61960fc8950ade44d00e3de28f891af0acc96307c73613d18f6e \
-    --hash=sha256:ca9c23848292c6fe0a19d212790e62f398fd9609aaa838859be8459bfbe558aa \
-    --hash=sha256:cc1b30205d138d1005adb52087ff45708febbef0e420386f58664f984ef56954 \
-    --hash=sha256:dbce7adeb66b895c6aaa1fad796aaefc299ced597f6fbd9ceddb0dd735245354 \
-    --hash=sha256:dc4b2fb01f1b4ddbe2453468ea0719f4dbb1f5caa712c8b21bb3dd1480cd30d9 \
-    --hash=sha256:eed2afaa97ec33b4411995be12f8bdb95c87984eaa28d76cf628970c8a2d689a \
-    --hash=sha256:fc7a7d7b0ed72589fd8b8486b9b42a564f10b8762be8bd4d9df94b807af4a089
+numpy==1.23.5 \
+    --hash=sha256:01dd17cbb340bf0fc23981e52e1d18a9d4050792e8fb8363cecbf066a84b827d \
+    --hash=sha256:06005a2ef6014e9956c09ba07654f9837d9e26696a0470e42beedadb78c11b07 \
+    --hash=sha256:09b7847f7e83ca37c6e627682f145856de331049013853f344f37b0c9690e3df \
+    --hash=sha256:0aaee12d8883552fadfc41e96b4c82ee7d794949e2a7c3b3a7201e968c7ecab9 \
+    --hash=sha256:0cbe9848fad08baf71de1a39e12d1b6310f1d5b2d0ea4de051058e6e1076852d \
+    --hash=sha256:1b1766d6f397c18153d40015ddfc79ddb715cabadc04d2d228d4e5a8bc4ded1a \
+    --hash=sha256:33161613d2269025873025b33e879825ec7b1d831317e68f4f2f0f84ed14c719 \
+    --hash=sha256:5039f55555e1eab31124a5768898c9e22c25a65c1e0037f4d7c495a45778c9f2 \
+    --hash=sha256:522e26bbf6377e4d76403826ed689c295b0b238f46c28a7251ab94716da0b280 \
+    --hash=sha256:56e454c7833e94ec9769fa0f86e6ff8e42ee38ce0ce1fa4cbb747ea7e06d56aa \
+    --hash=sha256:58f545efd1108e647604a1b5aa809591ccd2540f468a880bedb97247e72db387 \
+    --hash=sha256:5e05b1c973a9f858c74367553e236f287e749465f773328c8ef31abe18f691e1 \
+    --hash=sha256:7903ba8ab592b82014713c491f6c5d3a1cde5b4a3bf116404e08f5b52f6daf43 \
+    --hash=sha256:8969bfd28e85c81f3f94eb4a66bc2cf1dbdc5c18efc320af34bffc54d6b1e38f \
+    --hash=sha256:92c8c1e89a1f5028a4c6d9e3ccbe311b6ba53694811269b992c0b224269e2398 \
+    --hash=sha256:9c88793f78fca17da0145455f0d7826bcb9f37da4764af27ac945488116efe63 \
+    --hash=sha256:a7ac231a08bb37f852849bbb387a20a57574a97cfc7b6cabb488a4fc8be176de \
+    --hash=sha256:abdde9f795cf292fb9651ed48185503a2ff29be87770c3b8e2a14b0cd7aa16f8 \
+    --hash=sha256:af1da88f6bc3d2338ebbf0e22fe487821ea4d8e89053e25fa59d1d79786e7481 \
+    --hash=sha256:b2a9ab7c279c91974f756c84c365a669a887efa287365a8e2c418f8b3ba73fb0 \
+    --hash=sha256:bf837dc63ba5c06dc8797c398db1e223a466c7ece27a1f7b5232ba3466aafe3d \
+    --hash=sha256:ca51fcfcc5f9354c45f400059e88bc09215fb71a48d3768fb80e357f3b457e1e \
+    --hash=sha256:ce571367b6dfe60af04e04a1834ca2dc5f46004ac1cc756fb95319f64c095a96 \
+    --hash=sha256:d208a0f8729f3fb790ed18a003f3a57895b989b40ea4dce4717e9cf4af62c6bb \
+    --hash=sha256:dbee87b469018961d1ad79b1a5d50c0ae850000b639bcb1b694e9981083243b6 \
+    --hash=sha256:e9f4c4e51567b616be64e05d517c79a8a22f3606499941d97bb76f2ca59f982d \
+    --hash=sha256:f063b69b090c9d918f9df0a12116029e274daf0181df392839661c4c7ec9018a \
+    --hash=sha256:f9a909a8bae284d46bbfdefbdd4a262ba19d3bc9921b1e76126b1d21c3c34135
     # via
     #   -r tools/python/requirements.txt
+    #   contourpy
     #   matplotlib
     #   opencv-python
     #   osqp
@@ -228,23 +328,32 @@
     --hash=sha256:e6e448b62afc95c5b58f97e87ef84699e6607fe5c58730a03301c52496005cae \
     --hash=sha256:f482e78de6e7b0b060ff994ffd859bddc3f7f382bb2019ef157b0ea8ca8712f5
     # via -r tools/python/requirements.txt
-osqp==0.6.2.post5 \
-    --hash=sha256:26664bd4238f0f92642f532b23e61efba810a6debba0b3117300749f801e9c25 \
-    --hash=sha256:4ca601c5008600b3e0a408339be21f9d626c497b0b0c4dbe4ffe6d6dbbed1b9f \
-    --hash=sha256:51a315e02a4cb42e1911047ec6b2a44b67a269d4b5d37d7ee737654206915c82 \
-    --hash=sha256:648cb4e34caf0ee948b34a1d0b184f5233e30009090884e0d75503f868bf7b1f \
-    --hash=sha256:73a307a93fa7ab68b610e08637c95940070a27f11fda5a2e7a7095cfaff3f0ef \
-    --hash=sha256:77408f93ed261581fe498505c69480fb8584c8c0da2a2cd0710bb4bae0c872f5 \
-    --hash=sha256:8003fc363f707daa46fef3af548e6a580372154d6cd49a7bf2f569ba5f807d15 \
-    --hash=sha256:8c2e40e6788b860887d584a9929ad1c0e436aab8f82bb24da7b165034cb04017 \
-    --hash=sha256:908d42fb5d1d9cb36d74a8f3db69384ed1813f1a3e755367557395ce7cf05e16 \
-    --hash=sha256:b1e30d6fa10ed11a95023d7308ec1588de3f5b049d09a4d0cc49e057f8e9ce47 \
-    --hash=sha256:b2fa17aae42a7ed498ec261b33f262bb4b3605e7e8464062159d9fae817f0d61 \
-    --hash=sha256:c07602c8747ce7a177d091bb6d47ce8f214997a86b7577ddee4adae43e9ac92f \
-    --hash=sha256:c23bb95e6f72c6b253737edb9e4ef47ceccc3d891c287041ed5fe5f173d317bb \
-    --hash=sha256:c7b3ae95221ad6f607dc4a69f36b7a0c71ca434ce85dcbf5cfa084770be5b249 \
-    --hash=sha256:c9470c5d58535d31080cb693568916a3e837f09dfa94819a85284b36b3626738 \
-    --hash=sha256:ff71646bc9d55c5b3a72cc9b4197e51c36d25d8b2bb81f975d3ce7772ff188ec
+osqp==0.6.2.post8 \
+    --hash=sha256:02175818a0b1715ae0aab88a23678a44b269587af0ef655457042ca69a45eddd \
+    --hash=sha256:0a6e36151d088a9196b24fffc6b1d3a8bf79dcf9e7a5bd5f9c76c9ee1e019edf \
+    --hash=sha256:1d635a321686d15aaf2d91b05f41f736333d6adb0639bc14fc1c22b2cfce9c80 \
+    --hash=sha256:1ecbd173c21805b64a0b736d051312241a84327759526505578f83f7dcc81c66 \
+    --hash=sha256:22724b3ac4eaf17582e3ff35cb6660c026e71138f27fc21dbae4f1dc60904c64 \
+    --hash=sha256:23d6bae4a3612f60d5f652d0e5fa4b2ead507cabfff5d930d822057ae6ed6677 \
+    --hash=sha256:2cc3a966afc4c6ef29dbeb92c59aec7479451149bb77f5c318767433da2c1863 \
+    --hash=sha256:2d39020616c8b4fd9b3ec11f96bd3d68f366ab161323ecb9c1f9c7024eda2d28 \
+    --hash=sha256:2f8647e63bba38f57161d80dda251c06c290bb99e4767cc58a37727ee3c8b912 \
+    --hash=sha256:470c07e7dd06588576155133ae9aea62077dbaa4310aa8e387e879403de42369 \
+    --hash=sha256:497a2fb0d14d20185eaa32aa5f98374fe9a57df09ed0aedb2c27c37d0aa54afa \
+    --hash=sha256:52daa25502056aa1643e2d23ee230a7fe1c399e1a8b35a7b5dd2b77c7b356007 \
+    --hash=sha256:58b38557b0a6181dff8f557244758b955ff27384a1f67b83d75e51fd34c9e842 \
+    --hash=sha256:6a009c100eaaf93e9b2b790af61e209090d2a60b629893e21052d7216e572bbe \
+    --hash=sha256:7f888eaa54bac0261cadb145b3bcf8b2da9109cbf53fc4fdbdc6c6f6c04e2bb9 \
+    --hash=sha256:866f1bc2386b15393a68d379447808bbf3c8b2a126b0fc0669b27fcf3985b86c \
+    --hash=sha256:8d4920fb588d861d0d92874cb5b4435db16fe1e36a986d30638106afe374c1a8 \
+    --hash=sha256:ac9c6aaebe56eae33d7545564148a8fab1d71117cbbe0eedbd2c658bc3455df9 \
+    --hash=sha256:b30e7a2f49103622fdad9ed9c127c47afae01f5a8a6994d04803d3d5deadab4e \
+    --hash=sha256:bd956b7af9d524aed60ab41ec47b20519aede28538dea8f3188ad9056c4c0b01 \
+    --hash=sha256:c9705647d7e6171b3baaa68b0c159c43ea69cba22fbdbd8f79f86ae404a3d96f \
+    --hash=sha256:dd4b2ee44ec08253bcafb4d8a45c7d8278caa0bc13ac7ed24aa35249da7f1d2a \
+    --hash=sha256:dea8085760268971985bb3366bf4d5fb2e8291d7013c47e6178abb964cf05b86 \
+    --hash=sha256:e2475e1417e0ff86b5cd363d9dc2796d54f2a42f67a95fc527eb2ed15df6a1ac \
+    --hash=sha256:f30b405ec0e6a2acf52f59e04f1c258480be172f64c2d37c24adcbf2ac400548
     # via -r tools/python/requirements.txt
 packaging==21.3 \
     --hash=sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb \
@@ -252,70 +361,92 @@
     # via
     #   matplotlib
     #   mkdocs
-pillow==8.4.0 \
-    --hash=sha256:066f3999cb3b070a95c3652712cffa1a748cd02d60ad7b4e485c3748a04d9d76 \
-    --hash=sha256:0a0956fdc5defc34462bb1c765ee88d933239f9a94bc37d132004775241a7585 \
-    --hash=sha256:0b052a619a8bfcf26bd8b3f48f45283f9e977890263e4571f2393ed8898d331b \
-    --hash=sha256:1394a6ad5abc838c5cd8a92c5a07535648cdf6d09e8e2d6df916dfa9ea86ead8 \
-    --hash=sha256:1bc723b434fbc4ab50bb68e11e93ce5fb69866ad621e3c2c9bdb0cd70e345f55 \
-    --hash=sha256:244cf3b97802c34c41905d22810846802a3329ddcb93ccc432870243211c79fc \
-    --hash=sha256:25a49dc2e2f74e65efaa32b153527fc5ac98508d502fa46e74fa4fd678ed6645 \
-    --hash=sha256:2e4440b8f00f504ee4b53fe30f4e381aae30b0568193be305256b1462216feff \
-    --hash=sha256:3862b7256046fcd950618ed22d1d60b842e3a40a48236a5498746f21189afbbc \
-    --hash=sha256:3eb1ce5f65908556c2d8685a8f0a6e989d887ec4057326f6c22b24e8a172c66b \
-    --hash=sha256:3f97cfb1e5a392d75dd8b9fd274d205404729923840ca94ca45a0af57e13dbe6 \
-    --hash=sha256:493cb4e415f44cd601fcec11c99836f707bb714ab03f5ed46ac25713baf0ff20 \
-    --hash=sha256:4acc0985ddf39d1bc969a9220b51d94ed51695d455c228d8ac29fcdb25810e6e \
-    --hash=sha256:5503c86916d27c2e101b7f71c2ae2cddba01a2cf55b8395b0255fd33fa4d1f1a \
-    --hash=sha256:5b7bb9de00197fb4261825c15551adf7605cf14a80badf1761d61e59da347779 \
-    --hash=sha256:5e9ac5f66616b87d4da618a20ab0a38324dbe88d8a39b55be8964eb520021e02 \
-    --hash=sha256:620582db2a85b2df5f8a82ddeb52116560d7e5e6b055095f04ad828d1b0baa39 \
-    --hash=sha256:62cc1afda735a8d109007164714e73771b499768b9bb5afcbbee9d0ff374b43f \
-    --hash=sha256:70ad9e5c6cb9b8487280a02c0ad8a51581dcbbe8484ce058477692a27c151c0a \
-    --hash=sha256:72b9e656e340447f827885b8d7a15fc8c4e68d410dc2297ef6787eec0f0ea409 \
-    --hash=sha256:72cbcfd54df6caf85cc35264c77ede902452d6df41166010262374155947460c \
-    --hash=sha256:792e5c12376594bfcb986ebf3855aa4b7c225754e9a9521298e460e92fb4a488 \
-    --hash=sha256:7b7017b61bbcdd7f6363aeceb881e23c46583739cb69a3ab39cb384f6ec82e5b \
-    --hash=sha256:81f8d5c81e483a9442d72d182e1fb6dcb9723f289a57e8030811bac9ea3fef8d \
-    --hash=sha256:82aafa8d5eb68c8463b6e9baeb4f19043bb31fefc03eb7b216b51e6a9981ae09 \
-    --hash=sha256:84c471a734240653a0ec91dec0996696eea227eafe72a33bd06c92697728046b \
-    --hash=sha256:8c803ac3c28bbc53763e6825746f05cc407b20e4a69d0122e526a582e3b5e153 \
-    --hash=sha256:93ce9e955cc95959df98505e4608ad98281fff037350d8c2671c9aa86bcf10a9 \
-    --hash=sha256:9a3e5ddc44c14042f0844b8cf7d2cd455f6cc80fd7f5eefbe657292cf601d9ad \
-    --hash=sha256:a4901622493f88b1a29bd30ec1a2f683782e57c3c16a2dbc7f2595ba01f639df \
-    --hash=sha256:a5a4532a12314149d8b4e4ad8ff09dde7427731fcfa5917ff16d0291f13609df \
-    --hash=sha256:b8831cb7332eda5dc89b21a7bce7ef6ad305548820595033a4b03cf3091235ed \
-    --hash=sha256:b8e2f83c56e141920c39464b852de3719dfbfb6e3c99a2d8da0edf4fb33176ed \
-    --hash=sha256:c70e94281588ef053ae8998039610dbd71bc509e4acbc77ab59d7d2937b10698 \
-    --hash=sha256:c8a17b5d948f4ceeceb66384727dde11b240736fddeda54ca740b9b8b1556b29 \
-    --hash=sha256:d82cdb63100ef5eedb8391732375e6d05993b765f72cb34311fab92103314649 \
-    --hash=sha256:d89363f02658e253dbd171f7c3716a5d340a24ee82d38aab9183f7fdf0cdca49 \
-    --hash=sha256:d99ec152570e4196772e7a8e4ba5320d2d27bf22fdf11743dd882936ed64305b \
-    --hash=sha256:ddc4d832a0f0b4c52fff973a0d44b6c99839a9d016fe4e6a1cb8f3eea96479c2 \
-    --hash=sha256:e3dacecfbeec9a33e932f00c6cd7996e62f53ad46fbe677577394aaa90ee419a \
-    --hash=sha256:eb9fc393f3c61f9054e1ed26e6fe912c7321af2f41ff49d3f83d05bacf22cc78
+pillow==9.3.0 \
+    --hash=sha256:03150abd92771742d4a8cd6f2fa6246d847dcd2e332a18d0c15cc75bf6703040 \
+    --hash=sha256:073adb2ae23431d3b9bcbcff3fe698b62ed47211d0716b067385538a1b0f28b8 \
+    --hash=sha256:0b07fffc13f474264c336298d1b4ce01d9c5a011415b79d4ee5527bb69ae6f65 \
+    --hash=sha256:0b7257127d646ff8676ec8a15520013a698d1fdc48bc2a79ba4e53df792526f2 \
+    --hash=sha256:12ce4932caf2ddf3e41d17fc9c02d67126935a44b86df6a206cf0d7161548627 \
+    --hash=sha256:15c42fb9dea42465dfd902fb0ecf584b8848ceb28b41ee2b58f866411be33f07 \
+    --hash=sha256:18498994b29e1cf86d505edcb7edbe814d133d2232d256db8c7a8ceb34d18cef \
+    --hash=sha256:1c7c8ae3864846fc95f4611c78129301e203aaa2af813b703c55d10cc1628535 \
+    --hash=sha256:22b012ea2d065fd163ca096f4e37e47cd8b59cf4b0fd47bfca6abb93df70b34c \
+    --hash=sha256:276a5ca930c913f714e372b2591a22c4bd3b81a418c0f6635ba832daec1cbcfc \
+    --hash=sha256:2e0918e03aa0c72ea56edbb00d4d664294815aa11291a11504a377ea018330d3 \
+    --hash=sha256:3033fbe1feb1b59394615a1cafaee85e49d01b51d54de0cbf6aa8e64182518a1 \
+    --hash=sha256:3168434d303babf495d4ba58fc22d6604f6e2afb97adc6a423e917dab828939c \
+    --hash=sha256:32a44128c4bdca7f31de5be641187367fe2a450ad83b833ef78910397db491aa \
+    --hash=sha256:3dd6caf940756101205dffc5367babf288a30043d35f80936f9bfb37f8355b32 \
+    --hash=sha256:40e1ce476a7804b0fb74bcfa80b0a2206ea6a882938eaba917f7a0f004b42502 \
+    --hash=sha256:41e0051336807468be450d52b8edd12ac60bebaa97fe10c8b660f116e50b30e4 \
+    --hash=sha256:4390e9ce199fc1951fcfa65795f239a8a4944117b5935a9317fb320e7767b40f \
+    --hash=sha256:502526a2cbfa431d9fc2a079bdd9061a2397b842bb6bc4239bb176da00993812 \
+    --hash=sha256:51e0e543a33ed92db9f5ef69a0356e0b1a7a6b6a71b80df99f1d181ae5875636 \
+    --hash=sha256:57751894f6618fd4308ed8e0c36c333e2f5469744c34729a27532b3db106ee20 \
+    --hash=sha256:5d77adcd56a42d00cc1be30843d3426aa4e660cab4a61021dc84467123f7a00c \
+    --hash=sha256:655a83b0058ba47c7c52e4e2df5ecf484c1b0b0349805896dd350cbc416bdd91 \
+    --hash=sha256:68943d632f1f9e3dce98908e873b3a090f6cba1cbb1b892a9e8d97c938871fbe \
+    --hash=sha256:6c738585d7a9961d8c2821a1eb3dcb978d14e238be3d70f0a706f7fa9316946b \
+    --hash=sha256:73bd195e43f3fadecfc50c682f5055ec32ee2c933243cafbfdec69ab1aa87cad \
+    --hash=sha256:772a91fc0e03eaf922c63badeca75e91baa80fe2f5f87bdaed4280662aad25c9 \
+    --hash=sha256:77ec3e7be99629898c9a6d24a09de089fa5356ee408cdffffe62d67bb75fdd72 \
+    --hash=sha256:7db8b751ad307d7cf238f02101e8e36a128a6cb199326e867d1398067381bff4 \
+    --hash=sha256:801ec82e4188e935c7f5e22e006d01611d6b41661bba9fe45b60e7ac1a8f84de \
+    --hash=sha256:82409ffe29d70fd733ff3c1025a602abb3e67405d41b9403b00b01debc4c9a29 \
+    --hash=sha256:828989c45c245518065a110434246c44a56a8b2b2f6347d1409c787e6e4651ee \
+    --hash=sha256:829f97c8e258593b9daa80638aee3789b7df9da5cf1336035016d76f03b8860c \
+    --hash=sha256:871b72c3643e516db4ecf20efe735deb27fe30ca17800e661d769faab45a18d7 \
+    --hash=sha256:89dca0ce00a2b49024df6325925555d406b14aa3efc2f752dbb5940c52c56b11 \
+    --hash=sha256:90fb88843d3902fe7c9586d439d1e8c05258f41da473952aa8b328d8b907498c \
+    --hash=sha256:97aabc5c50312afa5e0a2b07c17d4ac5e865b250986f8afe2b02d772567a380c \
+    --hash=sha256:9aaa107275d8527e9d6e7670b64aabaaa36e5b6bd71a1015ddd21da0d4e06448 \
+    --hash=sha256:9f47eabcd2ded7698106b05c2c338672d16a6f2a485e74481f524e2a23c2794b \
+    --hash=sha256:a0a06a052c5f37b4ed81c613a455a81f9a3a69429b4fd7bb913c3fa98abefc20 \
+    --hash=sha256:ab388aaa3f6ce52ac1cb8e122c4bd46657c15905904b3120a6248b5b8b0bc228 \
+    --hash=sha256:ad58d27a5b0262c0c19b47d54c5802db9b34d38bbf886665b626aff83c74bacd \
+    --hash=sha256:ae5331c23ce118c53b172fa64a4c037eb83c9165aba3a7ba9ddd3ec9fa64a699 \
+    --hash=sha256:af0372acb5d3598f36ec0914deed2a63f6bcdb7b606da04dc19a88d31bf0c05b \
+    --hash=sha256:afa4107d1b306cdf8953edde0534562607fe8811b6c4d9a486298ad31de733b2 \
+    --hash=sha256:b03ae6f1a1878233ac620c98f3459f79fd77c7e3c2b20d460284e1fb370557d4 \
+    --hash=sha256:b0915e734b33a474d76c28e07292f196cdf2a590a0d25bcc06e64e545f2d146c \
+    --hash=sha256:b4012d06c846dc2b80651b120e2cdd787b013deb39c09f407727ba90015c684f \
+    --hash=sha256:b472b5ea442148d1c3e2209f20f1e0bb0eb556538690fa70b5e1f79fa0ba8dc2 \
+    --hash=sha256:b59430236b8e58840a0dfb4099a0e8717ffb779c952426a69ae435ca1f57210c \
+    --hash=sha256:b90f7616ea170e92820775ed47e136208e04c967271c9ef615b6fbd08d9af0e3 \
+    --hash=sha256:b9a65733d103311331875c1dca05cb4606997fd33d6acfed695b1232ba1df193 \
+    --hash=sha256:bac18ab8d2d1e6b4ce25e3424f709aceef668347db8637c2296bcf41acb7cf48 \
+    --hash=sha256:bca31dd6014cb8b0b2db1e46081b0ca7d936f856da3b39744aef499db5d84d02 \
+    --hash=sha256:be55f8457cd1eac957af0c3f5ece7bc3f033f89b114ef30f710882717670b2a8 \
+    --hash=sha256:c7025dce65566eb6e89f56c9509d4f628fddcedb131d9465cacd3d8bac337e7e \
+    --hash=sha256:c935a22a557a560108d780f9a0fc426dd7459940dc54faa49d83249c8d3e760f \
+    --hash=sha256:dbb8e7f2abee51cef77673be97760abff1674ed32847ce04b4af90f610144c7b \
+    --hash=sha256:e6ea6b856a74d560d9326c0f5895ef8050126acfdc7ca08ad703eb0081e82b74 \
+    --hash=sha256:ebf2029c1f464c59b8bdbe5143c79fa2045a581ac53679733d3a91d400ff9efb \
+    --hash=sha256:f1ff2ee69f10f13a9596480335f406dd1f70c3650349e2be67ca3139280cade0
     # via matplotlib
 pkginfo==1.8.3 \
     --hash=sha256:848865108ec99d4901b2f7e84058b6e7660aae8ae10164e015a6dcf5b242a594 \
     --hash=sha256:a84da4318dd86f870a9447a8c98340aa06216bfc6f2b7bdc4b8766984ae1867c
     # via -r tools/python/requirements.txt
-pycairo==1.21.0 \
-    --hash=sha256:251907f18a552df938aa3386657ff4b5a4937dde70e11aa042bc297957f4b74b \
-    --hash=sha256:26b72b813c6f9d495f71057eab89c13e70a21c92360e9265abc049e0a931fa39 \
-    --hash=sha256:31e1c4850db03201d33929cbe1905ce1b33202683ebda7bb0d4dba489115066b \
-    --hash=sha256:4357f20a6b1de8f1e8072a74ff68ab4c9a0ae698cd9f5c0f2b2cdd9b28b635f6 \
-    --hash=sha256:44a2ecf34968de07b3b9dfdcdbccbd25aa3cab267200f234f84e81481a73bbf6 \
-    --hash=sha256:6d37375aab9f2bb6136f076c19815d72108383baae89fbc0d6cb8e5092217d02 \
-    --hash=sha256:70936b19f967fa3cb3cd200c2608911227fa5d09dae21c166f64bc15e714ee41 \
-    --hash=sha256:dace6b356c476de27f8e1522428ac21a799c225703f746e2957d441f885dcb6c \
-    --hash=sha256:f63c153a9ea3d21aff85e2caeee4b0c5d566b2368b4ed64826020d12953d76a4
+pycairo==1.22.0 \
+    --hash=sha256:007ae728c56b9a0962d8c5513ae967a4fceff03e022940383c20f4f3d4c48dbe \
+    --hash=sha256:00c8a6b92c5075ee3be7ea1d33f676d259f11f92cad7e37077dd193437c8c27c \
+    --hash=sha256:356c9fc665e8522f497b6cbe026ad8decacbb04c93e13fd5d145956433f3d471 \
+    --hash=sha256:47aed13e950345c8248f77c8a51bff52188bef7afd3d5169584e0eddc21ba341 \
+    --hash=sha256:5a62cf1d2c6339028709a600d83c0c24111feedeef3cf977bca333fbb94a79c8 \
+    --hash=sha256:62ce5e8c97eeee70170ba9a74845a0ded4bde9b7f1701d88957cbadf8cb1ccd6 \
+    --hash=sha256:9fbe26b3fbe85fde063070e543b4a5f3609569ca8f79680867cecb837d5be29c \
+    --hash=sha256:b34517abdf619d4c7f0274f012b398d9b03bab7adc3efd2912bf36be3f911f3f \
+    --hash=sha256:b85807ec65a8b7966aca7aa41c39016b72515d6401a874a4b52c314471b31865 \
+    --hash=sha256:e31a5b70664c425f4d1b71ba8aaf259920de6937a9490132ffabadad2a89764f \
+    --hash=sha256:e81189414c11340134bffa6dcb06a378976cb87a6742f39aaefc79cb27612250
     # via pygobject
 pygobject==3.42.2 \
     --hash=sha256:21524cef33100c8fd59dc135948b703d79d303e368ce71fa60521cc971cd8aa7
     # via -r tools/python/requirements.txt
-pyparsing==3.0.6 \
-    --hash=sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4 \
-    --hash=sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81
+pyparsing==3.0.9 \
+    --hash=sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb \
+    --hash=sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc
     # via
     #   matplotlib
     #   packaging
@@ -395,36 +526,28 @@
     --hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983 \
     --hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349
     # via -r tools/python/requirements.txt
-scipy==1.7.3 \
-    --hash=sha256:033ce76ed4e9f62923e1f8124f7e2b0800db533828c853b402c7eec6e9465d80 \
-    --hash=sha256:173308efba2270dcd61cd45a30dfded6ec0085b4b6eb33b5eb11ab443005e088 \
-    --hash=sha256:21b66200cf44b1c3e86495e3a436fc7a26608f92b8d43d344457c54f1c024cbc \
-    --hash=sha256:2c56b820d304dffcadbbb6cbfbc2e2c79ee46ea291db17e288e73cd3c64fefa9 \
-    --hash=sha256:304dfaa7146cffdb75fbf6bb7c190fd7688795389ad060b970269c8576d038e9 \
-    --hash=sha256:3f78181a153fa21c018d346f595edd648344751d7f03ab94b398be2ad083ed3e \
-    --hash=sha256:4d242d13206ca4302d83d8a6388c9dfce49fc48fdd3c20efad89ba12f785bf9e \
-    --hash=sha256:5d1cc2c19afe3b5a546ede7e6a44ce1ff52e443d12b231823268019f608b9b12 \
-    --hash=sha256:5f2cfc359379c56b3a41b17ebd024109b2049f878badc1e454f31418c3a18436 \
-    --hash=sha256:65bd52bf55f9a1071398557394203d881384d27b9c2cad7df9a027170aeaef93 \
-    --hash=sha256:7edd9a311299a61e9919ea4192dd477395b50c014cdc1a1ac572d7c27e2207fa \
-    --hash=sha256:8499d9dd1459dc0d0fe68db0832c3d5fc1361ae8e13d05e6849b358dc3f2c279 \
-    --hash=sha256:866ada14a95b083dd727a845a764cf95dd13ba3dc69a16b99038001b05439709 \
-    --hash=sha256:87069cf875f0262a6e3187ab0f419f5b4280d3dcf4811ef9613c605f6e4dca95 \
-    --hash=sha256:93378f3d14fff07572392ce6a6a2ceb3a1f237733bd6dcb9eb6a2b29b0d19085 \
-    --hash=sha256:95c2d250074cfa76715d58830579c64dff7354484b284c2b8b87e5a38321672c \
-    --hash=sha256:ab5875facfdef77e0a47d5fd39ea178b58e60e454a4c85aa1e52fcb80db7babf \
-    --hash=sha256:b0e0aeb061a1d7dcd2ed59ea57ee56c9b23dd60100825f98238c06ee5cc4467e \
-    --hash=sha256:b78a35c5c74d336f42f44106174b9851c783184a85a3fe3e68857259b37b9ffb \
-    --hash=sha256:c9e04d7e9b03a8a6ac2045f7c5ef741be86727d8f49c45db45f244bdd2bcff17 \
-    --hash=sha256:ca36e7d9430f7481fc7d11e015ae16fbd5575615a8e9060538104778be84addf \
-    --hash=sha256:ceebc3c4f6a109777c0053dfa0282fddb8893eddfb0d598574acfb734a926168 \
-    --hash=sha256:e2c036492e673aad1b7b0d0ccdc0cb30a968353d2c4bf92ac8e73509e1bf212c \
-    --hash=sha256:eb326658f9b73c07081300daba90a8746543b5ea177184daed26528273157294 \
-    --hash=sha256:eb7ae2c4dbdb3c9247e07acc532f91077ae6dbc40ad5bd5dca0bb5a176ee9bda \
-    --hash=sha256:edad1cf5b2ce1912c4d8ddad20e11d333165552aba262c882e28c78bbc09dbf6 \
-    --hash=sha256:eef93a446114ac0193a7b714ce67659db80caf940f3232bad63f4c7a81bc18df \
-    --hash=sha256:f7eaea089345a35130bc9a39b89ec1ff69c208efa97b3f8b25ea5d4c41d88094 \
-    --hash=sha256:f99d206db1f1ae735a8192ab93bd6028f3a42f6fa08467d37a14eb96c9dd34a3
+scipy==1.9.3 \
+    --hash=sha256:06d2e1b4c491dc7d8eacea139a1b0b295f74e1a1a0f704c375028f8320d16e31 \
+    --hash=sha256:0d54222d7a3ba6022fdf5773931b5d7c56efe41ede7f7128c7b1637700409108 \
+    --hash=sha256:1884b66a54887e21addf9c16fb588720a8309a57b2e258ae1c7986d4444d3bc0 \
+    --hash=sha256:1a72d885fa44247f92743fc20732ae55564ff2a519e8302fb7e18717c5355a8b \
+    --hash=sha256:2318bef588acc7a574f5bfdff9c172d0b1bf2c8143d9582e05f878e580a3781e \
+    --hash=sha256:4db5b30849606a95dcf519763dd3ab6fe9bd91df49eba517359e450a7d80ce2e \
+    --hash=sha256:545c83ffb518094d8c9d83cce216c0c32f8c04aaf28b92cc8283eda0685162d5 \
+    --hash=sha256:5a04cd7d0d3eff6ea4719371cbc44df31411862b9646db617c99718ff68d4840 \
+    --hash=sha256:5b88e6d91ad9d59478fafe92a7c757d00c59e3bdc3331be8ada76a4f8d683f58 \
+    --hash=sha256:68239b6aa6f9c593da8be1509a05cb7f9efe98b80f43a5861cd24c7557e98523 \
+    --hash=sha256:83b89e9586c62e787f5012e8475fbb12185bafb996a03257e9675cd73d3736dd \
+    --hash=sha256:83c06e62a390a9167da60bedd4575a14c1f58ca9dfde59830fc42e5197283dab \
+    --hash=sha256:90453d2b93ea82a9f434e4e1cba043e779ff67b92f7a0e85d05d286a3625df3c \
+    --hash=sha256:abaf921531b5aeaafced90157db505e10345e45038c39e5d9b6c7922d68085cb \
+    --hash=sha256:b41bc822679ad1c9a5f023bc93f6d0543129ca0f37c1ce294dd9d386f0a21096 \
+    --hash=sha256:c68db6b290cbd4049012990d7fe71a2abd9ffbe82c0056ebe0f01df8be5436b0 \
+    --hash=sha256:cff3a5295234037e39500d35316a4c5794739433528310e117b8a9a0c76d20fc \
+    --hash=sha256:d01e1dd7b15bd2449c8bfc6b7cc67d630700ed655654f0dfcf121600bad205c9 \
+    --hash=sha256:d644a64e174c16cb4b2e41dfea6af722053e83d066da7343f333a54dae9bc31c \
+    --hash=sha256:da8245491d73ed0a994ed9c2e380fd058ce2fa8a18da204681f2fe1f57f98f95 \
+    --hash=sha256:fbc5c05c85c1a02be77b1ff591087c83bc44579c6d2bd9fb798bb64ea5e1a027
     # via
     #   -r tools/python/requirements.txt
     #   osqp
@@ -433,9 +556,9 @@
     --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
     --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
     # via python-dateutil
-urllib3==1.26.12 \
-    --hash=sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e \
-    --hash=sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997
+urllib3==1.26.13 \
+    --hash=sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc \
+    --hash=sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8
     # via requests
 watchdog==2.1.9 \
     --hash=sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412 \
@@ -464,7 +587,11 @@
     --hash=sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9 \
     --hash=sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658
     # via mkdocs
-zipp==3.8.1 \
-    --hash=sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2 \
-    --hash=sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009
+yapf==0.32.0 \
+    --hash=sha256:8fea849025584e486fd06d6ba2bed717f396080fd3cc236ba10cb97c4c51cf32 \
+    --hash=sha256:a3f5085d37ef7e3e004c4ba9f9b3e40c54ff1901cd111f05145ae313a7c67d1b
+    # via -r tools/python/requirements.txt
+zipp==3.11.0 \
+    --hash=sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa \
+    --hash=sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766
     # via importlib-metadata
diff --git a/tools/python/requirements.txt b/tools/python/requirements.txt
index 9bbecbf..a4cbd6a 100644
--- a/tools/python/requirements.txt
+++ b/tools/python/requirements.txt
@@ -11,3 +11,4 @@
 pygobject
 requests
 scipy
+yapf
diff --git a/tools/python/whl_overrides.json b/tools/python/whl_overrides.json
index 81c4701..3d724b9 100644
--- a/tools/python/whl_overrides.json
+++ b/tools/python/whl_overrides.json
@@ -1,7 +1,7 @@
 {
-    "certifi==2022.9.14": {
-        "sha256": "e232343de1ab72c2aa521b625c80f699e356830fd0e2c620b465b304b17b0516",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/certifi-2022.9.14-py3-none-any.whl"
+    "certifi==2022.9.24": {
+        "sha256": "90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/certifi-2022.9.24-py3-none-any.whl"
     },
     "charset_normalizer==2.1.1": {
         "sha256": "83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f",
@@ -11,13 +11,17 @@
         "sha256": "bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/click-8.1.3-py3-none-any.whl"
     },
+    "contourpy==1.0.6": {
+        "sha256": "1dedf4c64185a216c35eb488e6f433297c660321275734401760dafaeb0ad5c2",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/contourpy-1.0.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+    },
     "cycler==0.11.0": {
         "sha256": "3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/cycler-0.11.0-py3-none-any.whl"
     },
-    "fonttools==4.28.5": {
-        "sha256": "edf251d5d2cc0580d5f72de4621c338d8c66c5f61abb50cf486640f73c8194d5",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/fonttools-4.28.5-py3-none-any.whl"
+    "fonttools==4.38.0": {
+        "sha256": "820466f43c8be8c3009aef8b87e785014133508f0de64ec469e4efb643ae54fb",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/fonttools-4.38.0-py3-none-any.whl"
     },
     "ghp_import==2.1.0": {
         "sha256": "8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619",
@@ -27,17 +31,17 @@
         "sha256": "90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/idna-3.4-py3-none-any.whl"
     },
-    "importlib_metadata==5.0.0": {
-        "sha256": "ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/importlib_metadata-5.0.0-py3-none-any.whl"
+    "importlib_metadata==5.1.0": {
+        "sha256": "d84d17e21670ec07990e1044a99efe8d615d860fd176fc29ef5c306068fda313",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/importlib_metadata-5.1.0-py3-none-any.whl"
     },
     "jinja2==3.1.2": {
         "sha256": "6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/Jinja2-3.1.2-py3-none-any.whl"
     },
-    "kiwisolver==1.3.2": {
-        "sha256": "30fa008c172355c7768159983a7270cb23838c4d7db73d6c0f6b60dde0d432c6",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/kiwisolver-1.3.2-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"
+    "kiwisolver==1.4.4": {
+        "sha256": "7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"
     },
     "markdown==3.3.7": {
         "sha256": "f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621",
@@ -47,53 +51,53 @@
         "sha256": "56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
     },
-    "matplotlib==3.5.1": {
-        "sha256": "87900c67c0f1728e6db17c6809ec05c025c6624dcf96a8020326ea15378fe8e7",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/matplotlib-3.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl"
+    "matplotlib==3.6.2": {
+        "sha256": "795ad83940732b45d39b82571f87af0081c120feff2b12e748d96bb191169e33",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/matplotlib-3.6.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
     },
     "mergedeep==1.3.4": {
         "sha256": "70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/mergedeep-1.3.4-py3-none-any.whl"
     },
-    "mkdocs==1.4.0": {
-        "sha256": "ce057e9992f017b8e1496b591b6c242cbd34c2d406e2f9af6a19b97dd6248faa",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/mkdocs-1.4.0-py3-none-any.whl"
+    "mkdocs==1.4.2": {
+        "sha256": "c8856a832c1e56702577023cd64cc5f84948280c1c0fcc6af4cd39006ea6aa8c",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/mkdocs-1.4.2-py3-none-any.whl"
     },
-    "numpy==1.21.5": {
-        "sha256": "c293d3c0321996cd8ffe84215ffe5d269fd9d1d12c6f4ffe2b597a7c30d3e593",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/numpy-1.21.5-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"
+    "numpy==1.23.5": {
+        "sha256": "33161613d2269025873025b33e879825ec7b1d831317e68f4f2f0f84ed14c719",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/numpy-1.23.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
     },
     "opencv_python==4.6.0.66": {
         "sha256": "dbdc84a9b4ea2cbae33861652d25093944b9959279200b7ae0badd32439f74de",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/opencv_python-4.6.0.66-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
     },
-    "osqp==0.6.2.post5": {
-        "sha256": "8003fc363f707daa46fef3af548e6a580372154d6cd49a7bf2f569ba5f807d15",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/osqp-0.6.2.post5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+    "osqp==0.6.2.post8": {
+        "sha256": "22724b3ac4eaf17582e3ff35cb6660c026e71138f27fc21dbae4f1dc60904c64",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/osqp-0.6.2.post8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
     },
     "packaging==21.3": {
         "sha256": "ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/packaging-21.3-py3-none-any.whl"
     },
-    "pillow==8.4.0": {
-        "sha256": "b8831cb7332eda5dc89b21a7bce7ef6ad305548820595033a4b03cf3091235ed",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/Pillow-8.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+    "pillow==9.3.0": {
+        "sha256": "97aabc5c50312afa5e0a2b07c17d4ac5e865b250986f8afe2b02d772567a380c",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/Pillow-9.3.0-cp39-cp39-manylinux_2_28_x86_64.whl"
     },
     "pkginfo==1.8.3": {
         "sha256": "848865108ec99d4901b2f7e84058b6e7660aae8ae10164e015a6dcf5b242a594",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/pkginfo-1.8.3-py2.py3-none-any.whl"
     },
-    "pycairo==1.21.0": {
-        "sha256": "c1fc681494d470c6af4864991ea406d1344680af69e060af06f7e8391c756ac0",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/pycairo-1.21.0-cp39-cp39-manylinux_2_31_x86_64.whl"
+    "pycairo==1.22.0": {
+        "sha256": "6d8325547b2ee5476d317045ca5824901309cc5444dced73bd7d1262b3e18b83",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/pycairo-1.22.0-cp39-cp39-manylinux_2_31_x86_64.whl"
     },
     "pygobject==3.42.2": {
-        "sha256": "0ccbc4a4d8e3697a060fcff16f7c28780b429052e63277ab4efd78ae2ff0b110",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/PyGObject-3.42.2-cp39-cp39-manylinux_2_31_x86_64.whl"
+        "sha256": "c11807320f696b07525b97800570e80a6563a649f2950d66501e13474e5c3a36",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/PyGObject-3.42.2-cp39-cp39-linux_x86_64.whl"
     },
-    "pyparsing==3.0.6": {
-        "sha256": "04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/pyparsing-3.0.6-py3-none-any.whl"
+    "pyparsing==3.0.9": {
+        "sha256": "5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/pyparsing-3.0.9-py3-none-any.whl"
     },
     "python_dateutil==2.8.2": {
         "sha256": "961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9",
@@ -115,24 +119,28 @@
         "sha256": "8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/requests-2.28.1-py3-none-any.whl"
     },
-    "scipy==1.7.3": {
-        "sha256": "5d1cc2c19afe3b5a546ede7e6a44ce1ff52e443d12b231823268019f608b9b12",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/scipy-1.7.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
+    "scipy==1.9.3": {
+        "sha256": "c68db6b290cbd4049012990d7fe71a2abd9ffbe82c0056ebe0f01df8be5436b0",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/scipy-1.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
     },
     "six==1.16.0": {
         "sha256": "8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/six-1.16.0-py2.py3-none-any.whl"
     },
-    "urllib3==1.26.12": {
-        "sha256": "b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/urllib3-1.26.12-py2.py3-none-any.whl"
+    "urllib3==1.26.13": {
+        "sha256": "47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/urllib3-1.26.13-py2.py3-none-any.whl"
     },
     "watchdog==2.1.9": {
         "sha256": "4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6",
         "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/watchdog-2.1.9-py3-none-manylinux2014_x86_64.whl"
     },
-    "zipp==3.8.1": {
-        "sha256": "47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009",
-        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/zipp-3.8.1-py3-none-any.whl"
+    "yapf==0.32.0": {
+        "sha256": "8fea849025584e486fd06d6ba2bed717f396080fd3cc236ba10cb97c4c51cf32",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/yapf-0.32.0-py2.py3-none-any.whl"
+    },
+    "zipp==3.11.0": {
+        "sha256": "83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa",
+        "url": "https://software.frc971.org/Build-Dependencies/wheelhouse/zipp-3.11.0-py3-none-any.whl"
     }
 }
diff --git a/y2020/vision/calibration.cc b/y2020/vision/calibration.cc
index b5d1b32..c6bacdb 100644
--- a/y2020/vision/calibration.cc
+++ b/y2020/vision/calibration.cc
@@ -38,11 +38,21 @@
             event_loop, pi,
             [this](cv::Mat rgb_image,
                    const aos::monotonic_clock::time_point eof,
-                   std::vector<int> charuco_ids,
-                   std::vector<cv::Point2f> charuco_corners, bool valid,
-                   Eigen::Vector3d rvec_eigen, Eigen::Vector3d tvec_eigen) {
+                   std::vector<cv::Vec4i> charuco_ids,
+                   std::vector<std::vector<cv::Point2f>> charuco_corners,
+                   bool valid, std::vector<Eigen::Vector3d> rvecs_eigen,
+                   std::vector<Eigen::Vector3d> tvecs_eigen) {
               HandleCharuco(rgb_image, eof, charuco_ids, charuco_corners, valid,
-                            rvec_eigen, tvec_eigen);
+                            rvecs_eigen, tvecs_eigen);
+            }),
+        image_callback_(
+            event_loop,
+            absl::StrCat(
+                "/pi", std::to_string(aos::network::ParsePiNumber(pi).value()),
+                "/camera"),
+            [this](cv::Mat rgb_image,
+                   const aos::monotonic_clock::time_point eof) {
+              charuco_extractor_.HandleImage(rgb_image, eof);
             }) {
     CHECK(pi_number_) << ": Invalid pi number " << pi
                       << ", failed to parse pi number";
@@ -50,13 +60,16 @@
     CHECK(std::regex_match(camera_id_, re))
         << ": Invalid camera_id '" << camera_id_
         << "', should be of form YY-NN";
+    CHECK_EQ(FLAGS_target_type, "charuco")
+        << "Intrinsic calibration only works with Charuco board";
   }
 
   void HandleCharuco(cv::Mat rgb_image,
                      const aos::monotonic_clock::time_point /*eof*/,
-                     std::vector<int> charuco_ids,
-                     std::vector<cv::Point2f> charuco_corners, bool valid,
-                     Eigen::Vector3d rvec_eigen, Eigen::Vector3d tvec_eigen) {
+                     std::vector<cv::Vec4i> charuco_ids,
+                     std::vector<std::vector<cv::Point2f>> charuco_corners,
+                     bool valid, std::vector<Eigen::Vector3d> rvecs_eigen,
+                     std::vector<Eigen::Vector3d> tvecs_eigen) {
     // Reduce resolution displayed on remote viewer to prevent lag
     cv::resize(rgb_image, rgb_image,
                cv::Size(rgb_image.cols / 2, rgb_image.rows / 2));
@@ -78,12 +91,17 @@
     if (!valid) {
       return;
     }
+    CHECK(tvecs_eigen.size() == 1)
+        << "Charuco board should only return one translational pose";
+    CHECK(rvecs_eigen.size() == 1)
+        << "Charuco board should only return one rotational pose";
     // Calibration calculates rotation and translation delta from last image
     // stored to automatically capture next image
 
     Eigen::Affine3d H_board_camera =
-        Eigen::Translation3d(tvec_eigen) *
-        Eigen::AngleAxisd(rvec_eigen.norm(), rvec_eigen / rvec_eigen.norm());
+        Eigen::Translation3d(tvecs_eigen[0]) *
+        Eigen::AngleAxisd(rvecs_eigen[0].norm(),
+                          rvecs_eigen[0] / rvecs_eigen[0].norm());
     Eigen::Affine3d H_camera_board_ = H_board_camera.inverse();
     Eigen::Affine3d H_delta = H_board_camera * prev_H_camera_board_;
 
@@ -97,8 +115,8 @@
     bool store_image = false;
     double percent_motion =
         std::max<double>(r_norm / kDeltaRThreshold, t_norm / kDeltaTThreshold);
-    LOG(INFO) << all_charuco_ids_.size() << ": Moved " << percent_motion
-              << "% of what's needed";
+    LOG(INFO) << "Captured: " << all_charuco_ids_.size() << " points; Moved "
+              << percent_motion << "% of what's needed";
     // Verify that camera has moved enough from last stored image
     if (r_norm > kDeltaRThreshold || t_norm > kDeltaTThreshold) {
       // frame_ refers to deltas between current and last captured image
@@ -118,9 +136,10 @@
           frame_r_norm < kFrameDeltaRLimit && frame_t_norm < kFrameDeltaTLimit;
       double percent_stop = std::max<double>(frame_r_norm / kFrameDeltaRLimit,
                                              frame_t_norm / kFrameDeltaTLimit);
-      LOG(INFO) << all_charuco_ids_.size() << ": Moved enough ("
-                << percent_motion << "%); Need to stop (last motion was "
-                << percent_stop << "%";
+      LOG(INFO) << "Captured: " << all_charuco_ids_.size()
+                << "points; Moved enough (" << percent_motion
+                << "%); Need to stop (last motion was " << percent_stop
+                << "% of limit; needs to be < 1 to capture)";
     }
     prev_image_H_camera_board_ = H_camera_board_;
 
@@ -128,8 +147,13 @@
       if (valid) {
         prev_H_camera_board_ = H_camera_board_;
 
-        all_charuco_ids_.emplace_back(std::move(charuco_ids));
-        all_charuco_corners_.emplace_back(std::move(charuco_corners));
+        // Unpack the Charuco ids from Vec4i
+        std::vector<int> charuco_ids_int;
+        for (cv::Vec4i charuco_id : charuco_ids) {
+          charuco_ids_int.emplace_back(charuco_id[0]);
+        }
+        all_charuco_ids_.emplace_back(std::move(charuco_ids_int));
+        all_charuco_corners_.emplace_back(std::move(charuco_corners[0]));
 
         if (r_norm > kDeltaRThreshold) {
           LOG(INFO) << "Triggered by rotation delta = " << r_norm << " > "
@@ -164,7 +188,8 @@
           img_size, cameraMatrix, distCoeffs, rvecs, tvecs,
           stdDeviationsIntrinsics, stdDeviationsExtrinsics, perViewErrors,
           calibration_flags);
-      CHECK_LE(reprojection_error, 1.0) << ": Reproduction error is bad.";
+      CHECK_LE(reprojection_error, 1.0)
+          << ": Reproduction error is bad-- greater than 1 pixel.";
       LOG(INFO) << "Reprojection Error is " << reprojection_error;
 
       flatbuffers::FlatBufferBuilder fbb;
@@ -246,6 +271,7 @@
   Eigen::Affine3d prev_image_H_camera_board_;
 
   CharucoExtractor charuco_extractor_;
+  ImageCallback image_callback_;
 };
 
 namespace {
diff --git a/y2020/vision/extrinsics_calibration.cc b/y2020/vision/extrinsics_calibration.cc
index c561652..cf0c8f2 100644
--- a/y2020/vision/extrinsics_calibration.cc
+++ b/y2020/vision/extrinsics_calibration.cc
@@ -9,6 +9,7 @@
 #include "aos/time/time.h"
 #include "aos/util/file.h"
 #include "frc971/control_loops/quaternion_utils.h"
+#include "frc971/vision/charuco_lib.h"
 #include "frc971/vision/vision_generated.h"
 #include "frc971/wpilib/imu_batch_generated.h"
 #include "y2020/control_loops/superstructure/superstructure_status_generated.h"
@@ -40,6 +41,8 @@
     CHECK(aos::configuration::MultiNode(reader.configuration()));
 
     // Find the nodes we care about.
+    const aos::Node *const imu_node =
+        aos::configuration::GetNode(factory.configuration(), "imu");
     const aos::Node *const roborio_node =
         aos::configuration::GetNode(factory.configuration(), "roborio");
 
@@ -49,17 +52,20 @@
     const aos::Node *const pi_node = aos::configuration::GetNode(
         factory.configuration(), absl::StrCat("pi", *pi_number));
 
+    LOG(INFO) << "imu " << aos::FlatbufferToJson(imu_node);
     LOG(INFO) << "roboRIO " << aos::FlatbufferToJson(roborio_node);
     LOG(INFO) << "Pi " << aos::FlatbufferToJson(pi_node);
 
+    std::unique_ptr<aos::EventLoop> imu_event_loop =
+        factory.MakeEventLoop("calibration", imu_node);
     std::unique_ptr<aos::EventLoop> roborio_event_loop =
         factory.MakeEventLoop("calibration", roborio_node);
     std::unique_ptr<aos::EventLoop> pi_event_loop =
         factory.MakeEventLoop("calibration", pi_node);
 
     // Now, hook Calibration up to everything.
-    Calibration extractor(&factory, pi_event_loop.get(),
-                          roborio_event_loop.get(), FLAGS_pi, &data);
+    Calibration extractor(&factory, pi_event_loop.get(), imu_event_loop.get(),
+                          FLAGS_pi, &data);
 
     if (FLAGS_turret) {
       aos::NodeEventLoopFactory *roborio_factory =
@@ -89,25 +95,42 @@
           Eigen::Vector3d(0.0, 0.0, M_PI)));
   const Eigen::Quaternion<double> nominal_pivot_to_camera(
       Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()));
+  const Eigen::Quaternion<double> nominal_pivot_to_imu(
+      Eigen::AngleAxisd(0.0, Eigen::Vector3d::UnitX()));
   const Eigen::Quaternion<double> nominal_board_to_world(
       Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()));
+  Eigen::Matrix<double, 6, 1> nominal_initial_state =
+      Eigen::Matrix<double, 6, 1>::Zero();
+  // Set y value to -1 m (approx distance from imu to board/world
+  nominal_initial_state(1, 0) = -1.0;
 
   CalibrationParameters calibration_parameters;
   calibration_parameters.initial_orientation = nominal_initial_orientation;
   calibration_parameters.pivot_to_camera = nominal_pivot_to_camera;
+  calibration_parameters.pivot_to_imu = nominal_pivot_to_imu;
   calibration_parameters.board_to_world = nominal_board_to_world;
+  calibration_parameters.initial_state = nominal_initial_state;
+  if (data.turret_samples_size() > 0) {
+    LOG(INFO) << "Have turret, so using pivot setup";
+    calibration_parameters.has_pivot = true;
+  }
 
   Solve(data, &calibration_parameters);
   LOG(INFO) << "Nominal initial_orientation "
             << nominal_initial_orientation.coeffs().transpose();
   LOG(INFO) << "Nominal pivot_to_camera "
             << nominal_pivot_to_camera.coeffs().transpose();
-
-  LOG(INFO) << "pivot_to_camera delta "
+  LOG(INFO) << "Nominal pivot_to_camera (rot-xyz) "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   nominal_pivot_to_camera)
+                   .transpose();
+  LOG(INFO) << "pivot_to_camera change "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.pivot_to_camera *
                    nominal_pivot_to_camera.inverse())
                    .transpose();
+  LOG(INFO) << "Nominal pivot_to_imu "
+            << nominal_pivot_to_imu.coeffs().transpose();
   LOG(INFO) << "board_to_world delta "
             << frc971::controls::ToRotationVectorFromQuaternion(
                    calibration_parameters.board_to_world *
diff --git a/y2022/BUILD b/y2022/BUILD
index 5d29f04..4a351a5 100644
--- a/y2022/BUILD
+++ b/y2022/BUILD
@@ -47,6 +47,7 @@
     ],
     data = [
         ":aos_config",
+        ":message_bridge_client.sh",
         "//y2022/image_streamer:image_streamer_start",
     ],
     dirs = [
diff --git a/y2022/constants.cc b/y2022/constants.cc
index c97058e..444e87d 100644
--- a/y2022/constants.cc
+++ b/y2022/constants.cc
@@ -131,23 +131,23 @@
 
   // Interpolation table for comp and practice robots
   r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
-      {1.0, {0.05, 19.4}},
-      {1.6, {0.05, 19.4}},
-      {1.9, {0.1, 19.4}},
-      {2.12, {0.13, 19.4}},
-      {2.9, {0.24, 19.9}},
+      {1.0, {0.12, 19.4}},
+      {1.6, {0.12, 19.4}},
+      {1.9, {0.17, 19.4}},
+      {2.12, {0.21, 19.4}},
+      {2.9, {0.30, 19.9}},
 
-      {3.2, {0.26, 20.7}},
+      {3.2, {0.33, 20.1}},
 
-      {3.60, {0.33, 20.9}},
-      {4.50, {0.38, 22.5}},
-      {4.9, {0.4, 22.9}},
-      {5.4, {0.4, 23.9}},
+      {3.60, {0.39, 20.65}},
+      {4.50, {0.44, 22.3}},
+      {4.9, {0.43, 22.75}}, // up to here
+      {5.4, {0.43, 23.85}},
 
-      {6.0, {0.40, 25.4}},
-      {7.0, {0.37, 28.1}},
+      {6.0, {0.42, 25.3}},
+      {7.0, {0.40, 27.7}},
 
-      {10.0, {0.37, 28.1}},
+      {10.0, {0.40, 27.7}},
   });
 
   if (false) {
@@ -238,13 +238,22 @@
           0.0634440443622909 + 0.213601224728352 + 0.0657973101027296 -
           0.114726411377978 - 0.980314029089968 - 0.0266013159299456 +
           0.0631240002215899 + 0.222882504808653 + 0.0370686419434252 -
-          0.0965027214840068 - 0.126737479717192;
+          0.0965027214840068 - 0.126737479717192 - 0.0773753775457 +
+          2.8132444751306;
       turret->subsystem_params.zeroing_constants.measured_absolute_position =
-          1.3081068967929;
+          1.16683731504739;
 
       flipper_arm_left->potentiometer_offset = -6.4;
       flipper_arm_right->potentiometer_offset = 5.56;
 
+      *turret_range = ::frc971::constants::Range{
+          .lower_hard = -7.0,  // Back Hard
+          .upper_hard = 3.4,   // Front Hard
+          .lower = -6.4,       // Back Soft
+          .upper = 2.9         // Front Soft
+      };
+      turret_params->range = *turret_range;
+
       catapult_params->zeroing_constants.measured_absolute_position =
           1.71723370408082;
       catapult->potentiometer_offset = -2.03383240293769;
@@ -256,6 +265,8 @@
       break;
 
     case kPracticeTeamNumber:
+      catapult_params->range.lower = -0.885;
+
       r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
           {1.0, {0.08, 20.0}},
           {1.6, {0.08, 20.0}},
@@ -276,7 +287,8 @@
           {10.0, {0.39, 28.25}},
       });
 
-      climber->potentiometer_offset = -0.1209073362519 + 0.0760598;
+      climber->potentiometer_offset =
+          -0.1209073362519 + 0.0760598 - 0.0221716219244 - 0.00321684;
       intake_front->potentiometer_offset = 3.06604378582351 - 0.60745632979918;
       intake_front->subsystem_params.zeroing_constants
           .measured_absolute_position = 0.143667561169188;
@@ -291,14 +303,18 @@
           0.0718028442723373 - 0.0793332946417493 + 0.233707527214682 +
           0.0828349540635251 + 0.677740533247017 - 0.0828349540635251 -
           0.0903654044329345 - 0.105426305171759 - 0.150609007388226 -
-          0.0338870266623506 - 0.0677740533247011;
+          0.0338870266623506 - 0.0677740533247011 - 0.135548106649404 - 0.6852;
       turret->subsystem_params.zeroing_constants.measured_absolute_position =
-          1.50798193457968;
-      turret_range->upper = 2.9;
-      turret_range->lower = -6.4;
+          0.8306;
+      *turret_range = ::frc971::constants::Range{
+          .lower_hard = -7.0,  // Back Hard
+          .upper_hard = 3.4,   // Front Hard
+          .lower = -6.4,       // Back Soft
+          .upper = 2.9         // Front Soft
+      };
       turret_params->range = *turret_range;
-      flipper_arm_left->potentiometer_offset = -4.39536583413615;
-      flipper_arm_right->potentiometer_offset = 4.36264091401229;
+      flipper_arm_left->potentiometer_offset = -4.39536583413615 - 0.108401297910291;
+      flipper_arm_right->potentiometer_offset = 4.36264091401229 + 0.175896445665755;
 
       catapult_params->zeroing_constants.measured_absolute_position =
           1.62909518684227;
diff --git a/y2022/message_bridge_client.sh b/y2022/message_bridge_client.sh
index c81076a..733905e 100755
--- a/y2022/message_bridge_client.sh
+++ b/y2022/message_bridge_client.sh
@@ -5,7 +5,37 @@
   ping -c 1 pi1 -W 1 && break;
   sleep 1
 done
+while true;
+do
+  ping -c 1 pi2 -W 1 && break;
+  sleep 1
+done
+while true;
+do
+  ping -c 1 pi3 -W 1 && break;
+  sleep 1
+done
+while true;
+do
+  ping -c 1 pi4 -W 1 && break;
+  sleep 1
+done
+while true;
+do
+  ping -c 1 pi5 -W 1 && break;
+  sleep 1
+done
+while true;
+do
+  ping -c 1 pi6 -W 1 && break;
+  sleep 1
+done
+while true;
+do
+  ping -c 1 roborio -W 1 && break;
+  sleep 1
+done
 
 echo Pinged
 
-exec /home/admin/bin/message_bridge_client "$@"
+exec message_bridge_client "$@"
diff --git a/y2022/vision/BUILD b/y2022/vision/BUILD
index 4fda1ad..9726542 100644
--- a/y2022/vision/BUILD
+++ b/y2022/vision/BUILD
@@ -328,9 +328,9 @@
 )
 
 cc_binary(
-    name = "extrinsics_calibration",
+    name = "calibrate_extrinsics",
     srcs = [
-        "extrinsics_calibration.cc",
+        "calibrate_extrinsics.cc",
     ],
     target_compatible_with = ["@platforms//os:linux"],
     visibility = ["//y2022:__subpackages__"],
@@ -339,6 +339,7 @@
         "//aos/events/logging:log_reader",
         "//frc971/control_loops:profiled_subsystem_fbs",
         "//frc971/vision:extrinsics_calibration",
+        "//third_party:opencv",
         "//y2022/control_loops/superstructure:superstructure_status_fbs",
     ],
 )
diff --git a/y2022/vision/calibrate_extrinsics.cc b/y2022/vision/calibrate_extrinsics.cc
new file mode 100644
index 0000000..521992c
--- /dev/null
+++ b/y2022/vision/calibrate_extrinsics.cc
@@ -0,0 +1,236 @@
+#include "Eigen/Dense"
+#include "Eigen/Geometry"
+#include "absl/strings/str_format.h"
+#include "aos/events/logging/log_reader.h"
+#include "aos/init.h"
+#include "aos/network/team_number.h"
+#include "aos/time/time.h"
+#include "aos/util/file.h"
+#include "frc971/control_loops/quaternion_utils.h"
+#include "frc971/vision/extrinsics_calibration.h"
+#include "frc971/vision/vision_generated.h"
+#include "frc971/wpilib/imu_batch_generated.h"
+#include "y2020/vision/sift/sift_generated.h"
+#include "y2020/vision/sift/sift_training_generated.h"
+#include "y2020/vision/tools/python_code/sift_training_data.h"
+#include "y2022/control_loops/superstructure/superstructure_status_generated.h"
+
+DEFINE_string(pi, "pi-7971-2", "Pi name to calibrate.");
+DEFINE_bool(plot, false, "Whether to plot the resulting data.");
+DEFINE_bool(turret, true, "If true, the camera is on the turret");
+
+namespace frc971 {
+namespace vision {
+namespace chrono = std::chrono;
+using aos::distributed_clock;
+using aos::monotonic_clock;
+
+// TODO(austin): Source of IMU data?  Is it the same?
+// TODO(austin): Intrinsics data?
+
+void Main(int argc, char **argv) {
+  CalibrationData data;
+
+  {
+    // Now, accumulate all the data into the data object.
+    aos::logger::LogReader reader(
+        aos::logger::SortParts(aos::logger::FindLogs(argc, argv)));
+
+    aos::SimulatedEventLoopFactory factory(reader.configuration());
+    reader.Register(&factory);
+
+    CHECK(aos::configuration::MultiNode(reader.configuration()));
+
+    // Find the nodes we care about.
+    const aos::Node *const imu_node =
+        aos::configuration::GetNode(factory.configuration(), "imu");
+    const aos::Node *const roborio_node =
+        aos::configuration::GetNode(factory.configuration(), "roborio");
+
+    std::optional<uint16_t> pi_number = aos::network::ParsePiNumber(FLAGS_pi);
+    CHECK(pi_number);
+    LOG(INFO) << "Pi " << *pi_number;
+    const aos::Node *const pi_node = aos::configuration::GetNode(
+        factory.configuration(), absl::StrCat("pi", *pi_number));
+
+    LOG(INFO) << "imu " << aos::FlatbufferToJson(imu_node);
+    LOG(INFO) << "roboRIO " << aos::FlatbufferToJson(roborio_node);
+    LOG(INFO) << "Pi " << aos::FlatbufferToJson(pi_node);
+
+    std::unique_ptr<aos::EventLoop> imu_event_loop =
+        factory.MakeEventLoop("calibration", imu_node);
+    std::unique_ptr<aos::EventLoop> roborio_event_loop =
+        factory.MakeEventLoop("calibration", roborio_node);
+    std::unique_ptr<aos::EventLoop> pi_event_loop =
+        factory.MakeEventLoop("calibration", pi_node);
+
+    // Now, hook Calibration up to everything.
+    Calibration extractor(&factory, pi_event_loop.get(), imu_event_loop.get(),
+                          FLAGS_pi, &data);
+
+    if (FLAGS_turret) {
+      aos::NodeEventLoopFactory *roborio_factory =
+          factory.GetNodeEventLoopFactory(roborio_node->name()->string_view());
+      roborio_event_loop->MakeWatcher(
+          "/superstructure",
+          [roborio_factory, roborio_event_loop = roborio_event_loop.get(),
+           &data](const y2022::control_loops::superstructure::Status &status) {
+            data.AddTurret(
+                roborio_factory->ToDistributedClock(
+                    roborio_event_loop->context().monotonic_event_time),
+                Eigen::Vector2d(status.turret()->position(),
+                                status.turret()->velocity()));
+          });
+    }
+
+    factory.Run();
+
+    reader.Deregister();
+  }
+
+  LOG(INFO) << "Done with event_loop running";
+  CHECK(data.imu_samples_size() > 0) << "Didn't get any IMU data";
+  CHECK(data.camera_samples_size() > 0) << "Didn't get any camera observations";
+
+  // And now we have it, we can start processing it.
+  const Eigen::Quaternion<double> nominal_initial_orientation(
+      frc971::controls::ToQuaternionFromRotationVector(
+          Eigen::Vector3d(0.0, 0.0, M_PI)));
+  const Eigen::Quaternion<double> nominal_pivot_to_camera(
+      Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()));
+  const Eigen::Quaternion<double> nominal_pivot_to_imu(
+      Eigen::AngleAxisd(0.0, Eigen::Vector3d::UnitX()));
+  const Eigen::Quaternion<double> nominal_board_to_world(
+      Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()));
+  Eigen::Matrix<double, 6, 1> nominal_initial_state =
+      Eigen::Matrix<double, 6, 1>::Zero();
+  // Set x value to 0.5 m (center view on the board)
+  // nominal_initial_state(0, 0) = 0.5;
+  // Set y value to -1 m (approx distance from imu to board/world)
+  nominal_initial_state(1, 0) = -1.0;
+
+  CalibrationParameters calibration_parameters;
+  calibration_parameters.initial_orientation = nominal_initial_orientation;
+  calibration_parameters.pivot_to_camera = nominal_pivot_to_camera;
+  calibration_parameters.pivot_to_imu = nominal_pivot_to_imu;
+  calibration_parameters.board_to_world = nominal_board_to_world;
+  calibration_parameters.initial_state = nominal_initial_state;
+
+  // Show the inverse of pivot_to_camera, since camera_to_pivot tells where the
+  // camera is with respect to the pivot frame
+  const Eigen::Affine3d nominal_affine_pivot_to_camera =
+      Eigen::Translation3d(calibration_parameters.pivot_to_camera_translation) *
+      nominal_pivot_to_camera;
+  const Eigen::Quaterniond nominal_camera_to_pivot_rotation(
+      nominal_affine_pivot_to_camera.inverse().rotation());
+  const Eigen::Vector3d nominal_camera_to_pivot_translation(
+      nominal_affine_pivot_to_camera.inverse().translation());
+
+  if (data.turret_samples_size() > 0) {
+    LOG(INFO) << "Have turret, so using pivot setup";
+    calibration_parameters.has_pivot = true;
+  }
+
+  LOG(INFO) << "Initial Conditions for solver.  Assumes:\n"
+            << "1) board origin is same as world, but rotated pi/2 about "
+               "x-axis, so z points out\n"
+            << "2) pivot origin matches imu origin\n"
+            << "3) camera is offset from pivot (depends on which camera)";
+
+  LOG(INFO)
+      << "Nominal initial_orientation of imu w.r.t. world (angle-axis vector): "
+      << frc971::controls::ToRotationVectorFromQuaternion(
+             nominal_initial_orientation)
+             .transpose();
+  LOG(INFO) << "Nominal initial_state: \n"
+            << "Position: "
+            << nominal_initial_state.block<3, 1>(0, 0).transpose() << "\n"
+            << "Velocity: "
+            << nominal_initial_state.block<3, 1>(3, 0).transpose();
+  LOG(INFO) << "Nominal pivot_to_imu (angle-axis vector) "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   calibration_parameters.pivot_to_imu)
+                   .transpose();
+  LOG(INFO) << "Nominal pivot_to_imu translation: "
+            << calibration_parameters.pivot_to_imu_translation.transpose();
+  // TODO<Jim>: Might be nice to take out the rotation component that maps into
+  // camera image coordinates (with x right, y down, z forward)
+  LOG(INFO) << "Nominal camera_to_pivot (angle-axis vector): "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   nominal_camera_to_pivot_rotation)
+                   .transpose();
+  LOG(INFO) << "Nominal camera_to_pivot translation: "
+            << nominal_camera_to_pivot_translation.transpose();
+
+  Solve(data, &calibration_parameters);
+
+  LOG(INFO) << "RESULTS OF CALIBRATION SOLVER:";
+  LOG(INFO) << "initial_orientation of imu w.r.t. world (angle-axis vector): "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   calibration_parameters.initial_orientation)
+                   .transpose();
+  LOG(INFO)
+      << "initial_state: \n"
+      << "Position: "
+      << calibration_parameters.initial_state.block<3, 1>(0, 0).transpose()
+      << "\n"
+      << "Velocity: "
+      << calibration_parameters.initial_state.block<3, 1>(3, 0).transpose();
+
+  LOG(INFO) << "pivot_to_imu rotation (angle-axis vec) "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   calibration_parameters.pivot_to_imu)
+                   .transpose();
+  LOG(INFO) << "pivot_to_imu_translation "
+            << calibration_parameters.pivot_to_imu_translation.transpose();
+  const Eigen::Affine3d affine_pivot_to_camera =
+      Eigen::Translation3d(calibration_parameters.pivot_to_camera_translation) *
+      calibration_parameters.pivot_to_camera;
+  const Eigen::Quaterniond camera_to_pivot_rotation(
+      affine_pivot_to_camera.inverse().rotation());
+  const Eigen::Vector3d camera_to_pivot_translation(
+      affine_pivot_to_camera.inverse().translation());
+  LOG(INFO) << "camera to pivot (angle-axis vec): "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   camera_to_pivot_rotation)
+                   .transpose();
+  LOG(INFO) << "camera to pivot translation: "
+            << camera_to_pivot_translation.transpose();
+  LOG(INFO) << "board_to_world (rotation) "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   calibration_parameters.board_to_world)
+                   .transpose();
+  LOG(INFO) << "accelerometer bias "
+            << calibration_parameters.accelerometer_bias.transpose();
+  LOG(INFO) << "gyro_bias " << calibration_parameters.gyro_bias.transpose();
+  LOG(INFO) << "gravity " << 9.81 * calibration_parameters.gravity_scalar;
+
+  LOG(INFO) << "pivot_to_camera change "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   calibration_parameters.pivot_to_camera *
+                   nominal_pivot_to_camera.inverse())
+                   .transpose();
+  LOG(INFO) << "board_to_world delta "
+            << frc971::controls::ToRotationVectorFromQuaternion(
+                   calibration_parameters.board_to_world *
+                   nominal_board_to_world.inverse())
+                   .transpose();
+
+  if (FLAGS_visualize) {
+    LOG(INFO) << "Showing visualization";
+    Visualize(data, calibration_parameters);
+  }
+
+  if (FLAGS_plot) {
+    Plot(data, calibration_parameters);
+  }
+}
+
+}  // namespace vision
+}  // namespace frc971
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+
+  frc971::vision::Main(argc, argv);
+}
diff --git a/y2022/vision/camera_definition.py b/y2022/vision/camera_definition.py
index d3e44b7..3b34ca2 100644
--- a/y2022/vision/camera_definition.py
+++ b/y2022/vision/camera_definition.py
@@ -109,24 +109,24 @@
             camera_yaw = 0.0
             T = np.array([-9.5 * 0.0254, -3.5 * 0.0254, 34.5 * 0.0254])
         elif pi_number == "pi3":
-            camera_yaw = 179.0 * np.pi / 180.0
+            camera_yaw = 182.0 * np.pi / 180.0
             T = np.array([-9.5 * 0.0254, 3.5 * 0.0254, 34.5 * 0.0254])
         elif pi_number == "pi4":
             camera_yaw = -90.0 * np.pi / 180.0
             T = np.array([-10.25 * 0.0254, -5.0 * 0.0254, 27.5 * 0.0254])
     elif team_number == 9971:
         if pi_number == "pi1":
-            camera_yaw = 180.5 * np.pi / 180.0
+            camera_yaw = 179.0 * np.pi / 180.0
             T = np.array([-9.5 * 0.0254, 3.25 * 0.0254, 35.5 * 0.0254])
         elif pi_number == "pi2":
             camera_yaw = 0.0
             T = np.array([-9.0 * 0.0254, -3.25 * 0.0254, 35.5 * 0.0254])
         elif pi_number == "pi3":
             camera_yaw = 90.0 * np.pi / 180.0
-            T = np.array([-10.5 * 0.0254, -5.0 * 0.0254, 29.5 * 0.0254])
+            T = np.array([-10.5 * 0.0254, 5.0 * 0.0254, 29.5 * 0.0254])
         elif pi_number == "pi4":
             camera_yaw = -90.0 * np.pi / 180.0
-            T = np.array([-10.5 * 0.0254, 5.0 * 0.0254, 28.0 * 0.0254])
+            T = np.array([-10.5 * 0.0254, -5.0 * 0.0254, 28.5 * 0.0254])
     else:
         glog.fatal("Unknown team number for extrinsics")
 
diff --git a/y2022/vision/extrinsics_calibration.cc b/y2022/vision/extrinsics_calibration.cc
deleted file mode 100644
index 49f2ca3..0000000
--- a/y2022/vision/extrinsics_calibration.cc
+++ /dev/null
@@ -1,129 +0,0 @@
-#include "frc971/vision/extrinsics_calibration.h"
-
-#include "Eigen/Dense"
-#include "Eigen/Geometry"
-#include "absl/strings/str_format.h"
-#include "aos/events/logging/log_reader.h"
-#include "aos/init.h"
-#include "aos/network/team_number.h"
-#include "aos/time/time.h"
-#include "aos/util/file.h"
-#include "frc971/control_loops/quaternion_utils.h"
-#include "frc971/vision/vision_generated.h"
-#include "frc971/wpilib/imu_batch_generated.h"
-#include "y2022/control_loops/superstructure/superstructure_status_generated.h"
-#include "y2020/vision/sift/sift_generated.h"
-#include "y2020/vision/sift/sift_training_generated.h"
-#include "y2020/vision/tools/python_code/sift_training_data.h"
-
-DEFINE_string(pi, "pi-7971-2", "Pi name to calibrate.");
-DEFINE_bool(plot, false, "Whether to plot the resulting data.");
-
-namespace frc971 {
-namespace vision {
-namespace chrono = std::chrono;
-using aos::distributed_clock;
-using aos::monotonic_clock;
-
-// TODO(austin): Source of IMU data?  Is it the same?
-// TODO(austin): Intrinsics data?
-
-void Main(int argc, char **argv) {
-  CalibrationData data;
-
-  {
-    // Now, accumulate all the data into the data object.
-    aos::logger::LogReader reader(
-        aos::logger::SortParts(aos::logger::FindLogs(argc, argv)));
-
-    aos::SimulatedEventLoopFactory factory(reader.configuration());
-    reader.Register(&factory);
-
-    CHECK(aos::configuration::MultiNode(reader.configuration()));
-
-    // Find the nodes we care about.
-    const aos::Node *const roborio_node =
-        aos::configuration::GetNode(factory.configuration(), "roborio");
-
-    std::optional<uint16_t> pi_number = aos::network::ParsePiNumber(FLAGS_pi);
-    CHECK(pi_number);
-    LOG(INFO) << "Pi " << *pi_number;
-    const aos::Node *const pi_node = aos::configuration::GetNode(
-        factory.configuration(), absl::StrCat("pi", *pi_number));
-
-    LOG(INFO) << "roboRIO " << aos::FlatbufferToJson(roborio_node);
-    LOG(INFO) << "Pi " << aos::FlatbufferToJson(pi_node);
-
-    std::unique_ptr<aos::EventLoop> roborio_event_loop =
-        factory.MakeEventLoop("calibration", roborio_node);
-    std::unique_ptr<aos::EventLoop> pi_event_loop =
-        factory.MakeEventLoop("calibration", pi_node);
-
-    // Now, hook Calibration up to everything.
-    Calibration extractor(&factory, pi_event_loop.get(),
-                          roborio_event_loop.get(), FLAGS_pi, &data);
-
-    aos::NodeEventLoopFactory *roborio_factory =
-        factory.GetNodeEventLoopFactory(roborio_node->name()->string_view());
-    roborio_event_loop->MakeWatcher(
-        "/superstructure",
-        [roborio_factory, roborio_event_loop = roborio_event_loop.get(),
-         &data](const y2022::control_loops::superstructure::Status &status) {
-          data.AddTurret(
-              roborio_factory->ToDistributedClock(
-                  roborio_event_loop->context().monotonic_event_time),
-              Eigen::Vector2d(status.turret()->position(),
-                              status.turret()->velocity()));
-        });
-
-    factory.Run();
-
-    reader.Deregister();
-  }
-
-  LOG(INFO) << "Done with event_loop running";
-  // And now we have it, we can start processing it.
-
-  const Eigen::Quaternion<double> nominal_initial_orientation(
-      frc971::controls::ToQuaternionFromRotationVector(
-          Eigen::Vector3d(0.0, 0.0, M_PI)));
-  const Eigen::Quaternion<double> nominal_pivot_to_camera(
-      Eigen::AngleAxisd(-0.5 * M_PI, Eigen::Vector3d::UnitX()));
-  const Eigen::Quaternion<double> nominal_board_to_world(
-      Eigen::AngleAxisd(0.5 * M_PI, Eigen::Vector3d::UnitX()));
-
-  CalibrationParameters calibration_parameters;
-  calibration_parameters.initial_orientation = nominal_initial_orientation;
-  calibration_parameters.pivot_to_camera = nominal_pivot_to_camera;
-  calibration_parameters.board_to_world = nominal_board_to_world;
-
-  Solve(data, &calibration_parameters);
-  LOG(INFO) << "Nominal initial_orientation "
-            << nominal_initial_orientation.coeffs().transpose();
-  LOG(INFO) << "Nominal pivot_to_camera "
-            << nominal_pivot_to_camera.coeffs().transpose();
-
-  LOG(INFO) << "pivot_to_camera delta "
-            << frc971::controls::ToRotationVectorFromQuaternion(
-                   calibration_parameters.pivot_to_camera *
-                   nominal_pivot_to_camera.inverse())
-                   .transpose();
-  LOG(INFO) << "board_to_world delta "
-            << frc971::controls::ToRotationVectorFromQuaternion(
-                   calibration_parameters.board_to_world *
-                   nominal_board_to_world.inverse())
-                   .transpose();
-
-  if (FLAGS_plot) {
-    Plot(data, calibration_parameters);
-  }
-}
-
-}  // namespace vision
-}  // namespace frc971
-
-int main(int argc, char **argv) {
-  aos::InitGoogle(&argc, &argv);
-
-  frc971::vision::Main(argc, argv);
-}
diff --git a/y2022/y2022_imu.json b/y2022/y2022_imu.json
index 817f051..bd2b326 100644
--- a/y2022/y2022_imu.json
+++ b/y2022/y2022_imu.json
@@ -367,7 +367,7 @@
   "applications": [
     {
       "name": "message_bridge_client",
-      "executable_name": "message_bridge_client",
+      "executable_name": "message_bridge_client.sh",
       "nodes": [
         "imu"
       ]
diff --git a/y2022/y2022_logger.json b/y2022/y2022_logger.json
index a8a4bbd..0f790c7 100644
--- a/y2022/y2022_logger.json
+++ b/y2022/y2022_logger.json
@@ -462,6 +462,38 @@
       ]
     },
     {
+      "name": "/pi3/camera",
+      "type": "frc971.vision.CameraImage",
+      "source_node": "pi3",
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": [
+        "logger"
+      ],
+      "destination_nodes": [
+        {
+          "name": "logger",
+          "priority": 3,
+          "time_to_live": 500000000
+        }
+      ]
+    },
+    {
+      "name": "/localizer",
+      "type": "frc971.IMUValuesBatch",
+      "source_node": "imu",
+      "logger": "LOCAL_AND_REMOTE_LOGGER",
+      "logger_nodes": [
+        "logger"
+      ],
+      "destination_nodes": [
+        {
+          "name": "logger",
+          "priority": 3,
+          "time_to_live": 500000000
+        }
+      ]
+    },
+    {
       "name": "/pi4/camera/decimated",
       "type": "frc971.vision.CameraImage",
       "source_node": "pi4",
@@ -502,7 +534,7 @@
   "applications": [
     {
       "name": "logger_message_bridge_client",
-      "executable_name": "message_bridge_client",
+      "executable_name": "message_bridge_client.sh",
       "args": ["--rmem=8388608", "--rt_priority=16"],
       "nodes": [
         "logger"
diff --git a/y2022/y2022_pi_template.json b/y2022/y2022_pi_template.json
index a6b3f4a..99b04a1 100644
--- a/y2022/y2022_pi_template.json
+++ b/y2022/y2022_pi_template.json
@@ -186,7 +186,7 @@
       "name": "/pi{{ NUM }}/camera",
       "type": "y2022.vision.TargetEstimate",
       "source_node": "pi{{ NUM }}",
-      "frequency": 40,
+      "frequency": 80,
       "num_senders": 2,
       "max_size": 40000,
       "logger": "LOCAL_AND_REMOTE_LOGGER",
@@ -358,7 +358,7 @@
   "applications": [
     {
       "name": "message_bridge_client",
-      "executable_name": "message_bridge_client",
+      "executable_name": "message_bridge_client.sh",
       "args": ["--rt_priority=16"],
       "nodes": [
         "pi{{ NUM }}"