diff --git a/renv.lock b/renv.lock
new file mode 100644
index 0000000000000000000000000000000000000000..3cfba30ad40b9241ba57b7bb18d953397a1d49b6
--- /dev/null
+++ b/renv.lock
@@ -0,0 +1,1359 @@
+{
+  "R": {
+    "Version": "4.4.1",
+    "Repositories": [
+      {
+        "Name": "CRAN",
+        "URL": "https://cloud.r-project.org"
+      }
+    ]
+  },
+  "Packages": {
+    "DBI": {
+      "Package": "DBI",
+      "Version": "1.2.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "methods"
+      ],
+      "Hash": "065ae649b05f1ff66bb0c793107508f5"
+    },
+    "KernSmooth": {
+      "Package": "KernSmooth",
+      "Version": "2.23-24",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "stats"
+      ],
+      "Hash": "9f33a1ee37bbe8919eb2ec4b9f2473a5"
+    },
+    "MASS": {
+      "Package": "MASS",
+      "Version": "7.3-60.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics",
+        "methods",
+        "stats",
+        "utils"
+      ],
+      "Hash": "2f342c46163b0b54d7b64d1f798e2c78"
+    },
+    "Matrix": {
+      "Package": "Matrix",
+      "Version": "1.7-0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics",
+        "grid",
+        "lattice",
+        "methods",
+        "stats",
+        "utils"
+      ],
+      "Hash": "1920b2f11133b12350024297d8a4ff4a"
+    },
+    "Momocs": {
+      "Package": "Momocs",
+      "Version": "1.4.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "MASS",
+        "R",
+        "RColorBrewer",
+        "cluster",
+        "dendextend",
+        "dplyr",
+        "geometry",
+        "geomorph",
+        "ggplot2",
+        "grDevices",
+        "graphics",
+        "jpeg",
+        "magrittr",
+        "progress",
+        "sf",
+        "sp",
+        "tibble",
+        "utils",
+        "vegan"
+      ],
+      "Hash": "f24c88b653f3cabdabe158e0b4da49ba"
+    },
+    "R6": {
+      "Package": "R6",
+      "Version": "2.5.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "470851b6d5d0ac559e9d01bb352b4021"
+    },
+    "RColorBrewer": {
+      "Package": "RColorBrewer",
+      "Version": "1.1-3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "45f0398006e83a5b10b72a90663d8d8c"
+    },
+    "RRPP": {
+      "Package": "RRPP",
+      "Version": "2.0.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "Matrix",
+        "R",
+        "ape",
+        "ggplot2",
+        "parallel"
+      ],
+      "Hash": "83f8ff73aa9c5790c4d81879305caf16"
+    },
+    "Rcpp": {
+      "Package": "Rcpp",
+      "Version": "1.0.12",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "methods",
+        "utils"
+      ],
+      "Hash": "5ea2700d21e038ace58269ecdbeb9ec0"
+    },
+    "RcppProgress": {
+      "Package": "RcppProgress",
+      "Version": "0.4.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Hash": "1c0aa18b97e6aaa17f93b8b866c0ace5"
+    },
+    "abind": {
+      "Package": "abind",
+      "Version": "1.4-5",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "methods",
+        "utils"
+      ],
+      "Hash": "4f57884290cc75ab22f4af9e9d4ca862"
+    },
+    "ape": {
+      "Package": "ape",
+      "Version": "5.8",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "Rcpp",
+        "digest",
+        "graphics",
+        "lattice",
+        "methods",
+        "nlme",
+        "parallel",
+        "stats",
+        "utils"
+      ],
+      "Hash": "16b5ff4dff0ead9ea955f62f794b1535"
+    },
+    "base64enc": {
+      "Package": "base64enc",
+      "Version": "0.1-3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "543776ae6848fde2f48ff3816d0628bc"
+    },
+    "bmp": {
+      "Package": "bmp",
+      "Version": "0.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Hash": "c71b569d923cdc420491907928595aca"
+    },
+    "bslib": {
+      "Package": "bslib",
+      "Version": "0.7.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "base64enc",
+        "cachem",
+        "fastmap",
+        "grDevices",
+        "htmltools",
+        "jquerylib",
+        "jsonlite",
+        "lifecycle",
+        "memoise",
+        "mime",
+        "rlang",
+        "sass"
+      ],
+      "Hash": "8644cc53f43828f19133548195d7e59e"
+    },
+    "cachem": {
+      "Package": "cachem",
+      "Version": "1.1.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "fastmap",
+        "rlang"
+      ],
+      "Hash": "cd9a672193789068eb5a2aad65a0dedf"
+    },
+    "class": {
+      "Package": "class",
+      "Version": "7.3-22",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "MASS",
+        "R",
+        "stats",
+        "utils"
+      ],
+      "Hash": "f91f6b29f38b8c280f2b9477787d4bb2"
+    },
+    "classInt": {
+      "Package": "classInt",
+      "Version": "0.4-10",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "KernSmooth",
+        "R",
+        "class",
+        "e1071",
+        "grDevices",
+        "graphics",
+        "stats"
+      ],
+      "Hash": "f5a40793b1ae463a7ffb3902a95bf864"
+    },
+    "cli": {
+      "Package": "cli",
+      "Version": "3.6.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "utils"
+      ],
+      "Hash": "b21916dd77a27642b447374a5d30ecf3"
+    },
+    "cluster": {
+      "Package": "cluster",
+      "Version": "2.1.6",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics",
+        "stats",
+        "utils"
+      ],
+      "Hash": "0aaa05204035dc43ea0004b9c76611dd"
+    },
+    "colorspace": {
+      "Package": "colorspace",
+      "Version": "2.1-0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics",
+        "methods",
+        "stats"
+      ],
+      "Hash": "f20c47fd52fae58b4e377c37bb8c335b"
+    },
+    "cpp11": {
+      "Package": "cpp11",
+      "Version": "0.4.7",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "5a295d7d963cc5035284dcdbaf334f4e"
+    },
+    "crayon": {
+      "Package": "crayon",
+      "Version": "1.5.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "grDevices",
+        "methods",
+        "utils"
+      ],
+      "Hash": "859d96e65ef198fd43e82b9628d593ef"
+    },
+    "dendextend": {
+      "Package": "dendextend",
+      "Version": "1.17.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "datasets",
+        "ggplot2",
+        "magrittr",
+        "stats",
+        "utils",
+        "viridis"
+      ],
+      "Hash": "043fafb791081fc553f29021bd0a9a01"
+    },
+    "digest": {
+      "Package": "digest",
+      "Version": "0.6.35",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "utils"
+      ],
+      "Hash": "698ece7ba5a4fa4559e3d537e7ec3d31"
+    },
+    "downloader": {
+      "Package": "downloader",
+      "Version": "0.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "digest",
+        "utils"
+      ],
+      "Hash": "f4f2a915e0dedbdf016a83b63477349f"
+    },
+    "dplyr": {
+      "Package": "dplyr",
+      "Version": "1.1.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "R6",
+        "cli",
+        "generics",
+        "glue",
+        "lifecycle",
+        "magrittr",
+        "methods",
+        "pillar",
+        "rlang",
+        "tibble",
+        "tidyselect",
+        "utils",
+        "vctrs"
+      ],
+      "Hash": "fedd9d00c2944ff00a0e2696ccf048ec"
+    },
+    "e1071": {
+      "Package": "e1071",
+      "Version": "1.7-14",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "class",
+        "grDevices",
+        "graphics",
+        "methods",
+        "proxy",
+        "stats",
+        "utils"
+      ],
+      "Hash": "4ef372b716824753719a8a38b258442d"
+    },
+    "evaluate": {
+      "Package": "evaluate",
+      "Version": "0.24.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "methods"
+      ],
+      "Hash": "a1066cbc05caee9a4bf6d90f194ff4da"
+    },
+    "fansi": {
+      "Package": "fansi",
+      "Version": "1.0.6",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "utils"
+      ],
+      "Hash": "962174cf2aeb5b9eea581522286a911f"
+    },
+    "farver": {
+      "Package": "farver",
+      "Version": "2.1.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Hash": "680887028577f3fa2a81e410ed0d6e42"
+    },
+    "fastmap": {
+      "Package": "fastmap",
+      "Version": "1.2.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Hash": "aa5e1cd11c2d15497494c5292d7ffcc8"
+    },
+    "fontawesome": {
+      "Package": "fontawesome",
+      "Version": "0.5.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "htmltools",
+        "rlang"
+      ],
+      "Hash": "c2efdd5f0bcd1ea861c2d4e2a883a67d"
+    },
+    "fs": {
+      "Package": "fs",
+      "Version": "1.6.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "methods"
+      ],
+      "Hash": "15aeb8c27f5ea5161f9f6a641fafd93a"
+    },
+    "generics": {
+      "Package": "generics",
+      "Version": "0.1.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "methods"
+      ],
+      "Hash": "15e9634c0fcd294799e9b2e929ed1b86"
+    },
+    "geometry": {
+      "Package": "geometry",
+      "Version": "0.4.7",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "Rcpp",
+        "RcppProgress",
+        "linprog",
+        "lpSolve",
+        "magic"
+      ],
+      "Hash": "8e5ba8a115dee2730bab618934db4b85"
+    },
+    "geomorph": {
+      "Package": "geomorph",
+      "Version": "4.0.8",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "Matrix",
+        "R",
+        "RRPP",
+        "ape",
+        "ggplot2",
+        "grDevices",
+        "graphics",
+        "jpeg",
+        "parallel",
+        "rgl",
+        "stats",
+        "utils"
+      ],
+      "Hash": "e2fff8d6687edcac3bc86979ed4da3b4"
+    },
+    "ggplot2": {
+      "Package": "ggplot2",
+      "Version": "3.5.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "MASS",
+        "R",
+        "cli",
+        "glue",
+        "grDevices",
+        "grid",
+        "gtable",
+        "isoband",
+        "lifecycle",
+        "mgcv",
+        "rlang",
+        "scales",
+        "stats",
+        "tibble",
+        "vctrs",
+        "withr"
+      ],
+      "Hash": "44c6a2f8202d5b7e878ea274b1092426"
+    },
+    "glue": {
+      "Package": "glue",
+      "Version": "1.7.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "methods"
+      ],
+      "Hash": "e0b3a53876554bd45879e596cdb10a52"
+    },
+    "gridExtra": {
+      "Package": "gridExtra",
+      "Version": "2.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "grDevices",
+        "graphics",
+        "grid",
+        "gtable",
+        "utils"
+      ],
+      "Hash": "7d7f283939f563670a697165b2cf5560"
+    },
+    "gtable": {
+      "Package": "gtable",
+      "Version": "0.3.5",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "cli",
+        "glue",
+        "grid",
+        "lifecycle",
+        "rlang"
+      ],
+      "Hash": "e18861963cbc65a27736e02b3cd3c4a0"
+    },
+    "highr": {
+      "Package": "highr",
+      "Version": "0.11",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "xfun"
+      ],
+      "Hash": "d65ba49117ca223614f71b60d85b8ab7"
+    },
+    "hms": {
+      "Package": "hms",
+      "Version": "1.1.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "lifecycle",
+        "methods",
+        "pkgconfig",
+        "rlang",
+        "vctrs"
+      ],
+      "Hash": "b59377caa7ed00fa41808342002138f9"
+    },
+    "htmltools": {
+      "Package": "htmltools",
+      "Version": "0.5.8.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "base64enc",
+        "digest",
+        "fastmap",
+        "grDevices",
+        "rlang",
+        "utils"
+      ],
+      "Hash": "81d371a9cc60640e74e4ab6ac46dcedc"
+    },
+    "htmlwidgets": {
+      "Package": "htmlwidgets",
+      "Version": "1.6.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "grDevices",
+        "htmltools",
+        "jsonlite",
+        "knitr",
+        "rmarkdown",
+        "yaml"
+      ],
+      "Hash": "04291cc45198225444a397606810ac37"
+    },
+    "igraph": {
+      "Package": "igraph",
+      "Version": "2.0.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "Matrix",
+        "R",
+        "cli",
+        "cpp11",
+        "grDevices",
+        "graphics",
+        "lifecycle",
+        "magrittr",
+        "methods",
+        "pkgconfig",
+        "rlang",
+        "stats",
+        "utils",
+        "vctrs"
+      ],
+      "Hash": "c3b7d801d722e26e4cd888e042bf9af5"
+    },
+    "imager": {
+      "Package": "imager",
+      "Version": "1.0.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "Rcpp",
+        "downloader",
+        "grDevices",
+        "igraph",
+        "jpeg",
+        "magrittr",
+        "methods",
+        "png",
+        "purrr",
+        "readbitmap",
+        "stringr"
+      ],
+      "Hash": "cd7b885ad219b89e96523c1205670ccd"
+    },
+    "import": {
+      "Package": "import",
+      "Version": "1.3.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Hash": "e9b8f8b1861b31fd11b4b98d818050c7"
+    },
+    "isoband": {
+      "Package": "isoband",
+      "Version": "0.2.7",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "grid",
+        "utils"
+      ],
+      "Hash": "0080607b4a1a7b28979aecef976d8bc2"
+    },
+    "jpeg": {
+      "Package": "jpeg",
+      "Version": "0.1-10",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "031a0b683d001a7519202f0628fc0358"
+    },
+    "jquerylib": {
+      "Package": "jquerylib",
+      "Version": "0.1.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "htmltools"
+      ],
+      "Hash": "5aab57a3bd297eee1c1d862735972182"
+    },
+    "jsonlite": {
+      "Package": "jsonlite",
+      "Version": "1.8.8",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "methods"
+      ],
+      "Hash": "e1b9c55281c5adc4dd113652d9e26768"
+    },
+    "knitr": {
+      "Package": "knitr",
+      "Version": "1.47",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "evaluate",
+        "highr",
+        "methods",
+        "tools",
+        "xfun",
+        "yaml"
+      ],
+      "Hash": "7c99b2d55584b982717fcc0950378612"
+    },
+    "labeling": {
+      "Package": "labeling",
+      "Version": "0.4.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "graphics",
+        "stats"
+      ],
+      "Hash": "b64ec208ac5bc1852b285f665d6368b3"
+    },
+    "lattice": {
+      "Package": "lattice",
+      "Version": "0.22-6",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics",
+        "grid",
+        "stats",
+        "utils"
+      ],
+      "Hash": "cc5ac1ba4c238c7ca9fa6a87ca11a7e2"
+    },
+    "lifecycle": {
+      "Package": "lifecycle",
+      "Version": "1.0.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "cli",
+        "glue",
+        "rlang"
+      ],
+      "Hash": "b8552d117e1b808b09a832f589b79035"
+    },
+    "linprog": {
+      "Package": "linprog",
+      "Version": "0.9-4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "lpSolve"
+      ],
+      "Hash": "66e9d4ebd71ddcd6f86a2a9a34f5cdc5"
+    },
+    "lpSolve": {
+      "Package": "lpSolve",
+      "Version": "5.6.20",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Hash": "2801c8082e89ed84cc0dbe43de850d31"
+    },
+    "magic": {
+      "Package": "magic",
+      "Version": "1.6-1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "abind"
+      ],
+      "Hash": "1da6217cea8a3ef496258819b80770e1"
+    },
+    "magrittr": {
+      "Package": "magrittr",
+      "Version": "2.0.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "7ce2733a9826b3aeb1775d56fd305472"
+    },
+    "mclust": {
+      "Package": "mclust",
+      "Version": "6.1.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics",
+        "stats",
+        "utils"
+      ],
+      "Hash": "aa9cfd45e2c3297213e270d000d80655"
+    },
+    "memoise": {
+      "Package": "memoise",
+      "Version": "2.0.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "cachem",
+        "rlang"
+      ],
+      "Hash": "e2817ccf4a065c5d9d7f2cfbe7c1d78c"
+    },
+    "mgcv": {
+      "Package": "mgcv",
+      "Version": "1.9-1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "Matrix",
+        "R",
+        "graphics",
+        "methods",
+        "nlme",
+        "splines",
+        "stats",
+        "utils"
+      ],
+      "Hash": "110ee9d83b496279960e162ac97764ce"
+    },
+    "mime": {
+      "Package": "mime",
+      "Version": "0.12",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "tools"
+      ],
+      "Hash": "18e9c28c1d3ca1560ce30658b22ce104"
+    },
+    "munsell": {
+      "Package": "munsell",
+      "Version": "0.5.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "colorspace",
+        "methods"
+      ],
+      "Hash": "4fd8900853b746af55b81fda99da7695"
+    },
+    "nlme": {
+      "Package": "nlme",
+      "Version": "3.1-164",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "graphics",
+        "lattice",
+        "stats",
+        "utils"
+      ],
+      "Hash": "a623a2239e642806158bc4dc3f51565d"
+    },
+    "permute": {
+      "Package": "permute",
+      "Version": "0.9-7",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "stats"
+      ],
+      "Hash": "abf0ca85c1c752e0d04f46334e635046"
+    },
+    "pillar": {
+      "Package": "pillar",
+      "Version": "1.9.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "cli",
+        "fansi",
+        "glue",
+        "lifecycle",
+        "rlang",
+        "utf8",
+        "utils",
+        "vctrs"
+      ],
+      "Hash": "15da5a8412f317beeee6175fbc76f4bb"
+    },
+    "pkgconfig": {
+      "Package": "pkgconfig",
+      "Version": "2.0.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "utils"
+      ],
+      "Hash": "01f28d4278f15c76cddbea05899c5d6f"
+    },
+    "png": {
+      "Package": "png",
+      "Version": "0.1-8",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "bd54ba8a0a5faded999a7aab6e46b374"
+    },
+    "prettyunits": {
+      "Package": "prettyunits",
+      "Version": "1.2.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "6b01fc98b1e86c4f705ce9dcfd2f57c7"
+    },
+    "progress": {
+      "Package": "progress",
+      "Version": "1.2.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "R6",
+        "crayon",
+        "hms",
+        "prettyunits"
+      ],
+      "Hash": "f4625e061cb2865f111b47ff163a5ca6"
+    },
+    "proxy": {
+      "Package": "proxy",
+      "Version": "0.4-27",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "stats",
+        "utils"
+      ],
+      "Hash": "e0ef355c12942cf7a6b91a6cfaea8b3e"
+    },
+    "purrr": {
+      "Package": "purrr",
+      "Version": "1.0.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "cli",
+        "lifecycle",
+        "magrittr",
+        "rlang",
+        "vctrs"
+      ],
+      "Hash": "1cba04a4e9414bdefc9dcaa99649a8dc"
+    },
+    "rappdirs": {
+      "Package": "rappdirs",
+      "Version": "0.3.3",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "5e3c5dc0b071b21fa128676560dbe94d"
+    },
+    "readbitmap": {
+      "Package": "readbitmap",
+      "Version": "0.1.5",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "bmp",
+        "jpeg",
+        "png",
+        "tiff"
+      ],
+      "Hash": "ec19e2be05f06df441857070a456dbe5"
+    },
+    "renv": {
+      "Package": "renv",
+      "Version": "1.0.7",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "utils"
+      ],
+      "Hash": "397b7b2a265bc5a7a06852524dabae20"
+    },
+    "rgl": {
+      "Package": "rgl",
+      "Version": "1.3.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "R6",
+        "base64enc",
+        "grDevices",
+        "graphics",
+        "htmltools",
+        "htmlwidgets",
+        "jsonlite",
+        "knitr",
+        "magrittr",
+        "mime",
+        "stats",
+        "utils"
+      ],
+      "Hash": "54f8dcdef54a2c7737c0eec27d394dfd"
+    },
+    "rlang": {
+      "Package": "rlang",
+      "Version": "1.1.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "utils"
+      ],
+      "Hash": "3eec01f8b1dee337674b2e34ab1f9bc1"
+    },
+    "rmarkdown": {
+      "Package": "rmarkdown",
+      "Version": "2.27",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "bslib",
+        "evaluate",
+        "fontawesome",
+        "htmltools",
+        "jquerylib",
+        "jsonlite",
+        "knitr",
+        "methods",
+        "tinytex",
+        "tools",
+        "utils",
+        "xfun",
+        "yaml"
+      ],
+      "Hash": "27f9502e1cdbfa195f94e03b0f517484"
+    },
+    "s2": {
+      "Package": "s2",
+      "Version": "1.1.6",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "Rcpp",
+        "wk"
+      ],
+      "Hash": "32f7b1a15bb01ae809022960abad5363"
+    },
+    "sass": {
+      "Package": "sass",
+      "Version": "0.4.9",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R6",
+        "fs",
+        "htmltools",
+        "rappdirs",
+        "rlang"
+      ],
+      "Hash": "d53dbfddf695303ea4ad66f86e99b95d"
+    },
+    "scales": {
+      "Package": "scales",
+      "Version": "1.3.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "R6",
+        "RColorBrewer",
+        "cli",
+        "farver",
+        "glue",
+        "labeling",
+        "lifecycle",
+        "munsell",
+        "rlang",
+        "viridisLite"
+      ],
+      "Hash": "c19df082ba346b0ffa6f833e92de34d1"
+    },
+    "sf": {
+      "Package": "sf",
+      "Version": "1.0-16",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "DBI",
+        "R",
+        "Rcpp",
+        "classInt",
+        "grDevices",
+        "graphics",
+        "grid",
+        "magrittr",
+        "methods",
+        "s2",
+        "stats",
+        "tools",
+        "units",
+        "utils"
+      ],
+      "Hash": "ad57b543f7c3fca05213ba78ff63df9b"
+    },
+    "sp": {
+      "Package": "sp",
+      "Version": "2.1-4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics",
+        "grid",
+        "lattice",
+        "methods",
+        "stats",
+        "utils"
+      ],
+      "Hash": "75940133cca2e339afce15a586f85b11"
+    },
+    "stringi": {
+      "Package": "stringi",
+      "Version": "1.8.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "stats",
+        "tools",
+        "utils"
+      ],
+      "Hash": "39e1144fd75428983dc3f63aa53dfa91"
+    },
+    "stringr": {
+      "Package": "stringr",
+      "Version": "1.5.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "cli",
+        "glue",
+        "lifecycle",
+        "magrittr",
+        "rlang",
+        "stringi",
+        "vctrs"
+      ],
+      "Hash": "960e2ae9e09656611e0b8214ad543207"
+    },
+    "tibble": {
+      "Package": "tibble",
+      "Version": "3.2.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "fansi",
+        "lifecycle",
+        "magrittr",
+        "methods",
+        "pillar",
+        "pkgconfig",
+        "rlang",
+        "utils",
+        "vctrs"
+      ],
+      "Hash": "a84e2cc86d07289b3b6f5069df7a004c"
+    },
+    "tidyselect": {
+      "Package": "tidyselect",
+      "Version": "1.2.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "cli",
+        "glue",
+        "lifecycle",
+        "rlang",
+        "vctrs",
+        "withr"
+      ],
+      "Hash": "829f27b9c4919c16b593794a6344d6c0"
+    },
+    "tiff": {
+      "Package": "tiff",
+      "Version": "0.1-12",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "74b5f7f00e466398fdc0af8889add582"
+    },
+    "tinytex": {
+      "Package": "tinytex",
+      "Version": "0.51",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "xfun"
+      ],
+      "Hash": "d44e2fcd2e4e076f0aac540208559d1d"
+    },
+    "units": {
+      "Package": "units",
+      "Version": "0.8-5",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "Rcpp"
+      ],
+      "Hash": "119d19da480e873f72241ff6962ffd83"
+    },
+    "utf8": {
+      "Package": "utf8",
+      "Version": "1.2.4",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "62b65c52671e6665f803ff02954446e9"
+    },
+    "vctrs": {
+      "Package": "vctrs",
+      "Version": "0.6.5",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "cli",
+        "glue",
+        "lifecycle",
+        "rlang"
+      ],
+      "Hash": "c03fa420630029418f7e6da3667aac4a"
+    },
+    "vegan": {
+      "Package": "vegan",
+      "Version": "2.6-6.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "MASS",
+        "R",
+        "cluster",
+        "lattice",
+        "mgcv",
+        "permute"
+      ],
+      "Hash": "46a520e3fd3286168c3e4dc5e6fbb5b1"
+    },
+    "viridis": {
+      "Package": "viridis",
+      "Version": "0.6.5",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "ggplot2",
+        "gridExtra",
+        "viridisLite"
+      ],
+      "Hash": "acd96d9fa70adeea4a5a1150609b9745"
+    },
+    "viridisLite": {
+      "Package": "viridisLite",
+      "Version": "0.4.2",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "c826c7c4241b6fc89ff55aaea3fa7491"
+    },
+    "withr": {
+      "Package": "withr",
+      "Version": "3.0.0",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R",
+        "grDevices",
+        "graphics"
+      ],
+      "Hash": "d31b6c62c10dcf11ec530ca6b0dd5d35"
+    },
+    "wk": {
+      "Package": "wk",
+      "Version": "0.9.1",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "R"
+      ],
+      "Hash": "5d4545e140e36476f35f20d0ca87963e"
+    },
+    "xfun": {
+      "Package": "xfun",
+      "Version": "0.45",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Requirements": [
+        "grDevices",
+        "stats",
+        "tools"
+      ],
+      "Hash": "ca59c87fe305b16a9141a5874c3a7889"
+    },
+    "yaml": {
+      "Package": "yaml",
+      "Version": "2.3.8",
+      "Source": "Repository",
+      "Repository": "CRAN",
+      "Hash": "29240487a071f535f5e5d5a323b7afbd"
+    }
+  }
+}
diff --git a/renv/.gitignore b/renv/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..0ec0cbba2d7755cbd91956afaa585449906a1e16
--- /dev/null
+++ b/renv/.gitignore
@@ -0,0 +1,7 @@
+library/
+local/
+cellar/
+lock/
+python/
+sandbox/
+staging/
diff --git a/renv/activate.R b/renv/activate.R
new file mode 100644
index 0000000000000000000000000000000000000000..d13f9932a16a92986a514d8cb439662d85db3add
--- /dev/null
+++ b/renv/activate.R
@@ -0,0 +1,1220 @@
+
+local({
+
+  # the requested version of renv
+  version <- "1.0.7"
+  attr(version, "sha") <- NULL
+
+  # the project directory
+  project <- Sys.getenv("RENV_PROJECT")
+  if (!nzchar(project))
+    project <- getwd()
+
+  # use start-up diagnostics if enabled
+  diagnostics <- Sys.getenv("RENV_STARTUP_DIAGNOSTICS", unset = "FALSE")
+  if (diagnostics) {
+    start <- Sys.time()
+    profile <- tempfile("renv-startup-", fileext = ".Rprof")
+    utils::Rprof(profile)
+    on.exit({
+      utils::Rprof(NULL)
+      elapsed <- signif(difftime(Sys.time(), start, units = "auto"), digits = 2L)
+      writeLines(sprintf("- renv took %s to run the autoloader.", format(elapsed)))
+      writeLines(sprintf("- Profile: %s", profile))
+      print(utils::summaryRprof(profile))
+    }, add = TRUE)
+  }
+
+  # figure out whether the autoloader is enabled
+  enabled <- local({
+
+    # first, check config option
+    override <- getOption("renv.config.autoloader.enabled")
+    if (!is.null(override))
+      return(override)
+
+    # if we're being run in a context where R_LIBS is already set,
+    # don't load -- presumably we're being run as a sub-process and
+    # the parent process has already set up library paths for us
+    rcmd <- Sys.getenv("R_CMD", unset = NA)
+    rlibs <- Sys.getenv("R_LIBS", unset = NA)
+    if (!is.na(rlibs) && !is.na(rcmd))
+      return(FALSE)
+
+    # next, check environment variables
+    # TODO: prefer using the configuration one in the future
+    envvars <- c(
+      "RENV_CONFIG_AUTOLOADER_ENABLED",
+      "RENV_AUTOLOADER_ENABLED",
+      "RENV_ACTIVATE_PROJECT"
+    )
+
+    for (envvar in envvars) {
+      envval <- Sys.getenv(envvar, unset = NA)
+      if (!is.na(envval))
+        return(tolower(envval) %in% c("true", "t", "1"))
+    }
+
+    # enable by default
+    TRUE
+
+  })
+
+  # bail if we're not enabled
+  if (!enabled) {
+
+    # if we're not enabled, we might still need to manually load
+    # the user profile here
+    profile <- Sys.getenv("R_PROFILE_USER", unset = "~/.Rprofile")
+    if (file.exists(profile)) {
+      cfg <- Sys.getenv("RENV_CONFIG_USER_PROFILE", unset = "TRUE")
+      if (tolower(cfg) %in% c("true", "t", "1"))
+        sys.source(profile, envir = globalenv())
+    }
+
+    return(FALSE)
+
+  }
+
+  # avoid recursion
+  if (identical(getOption("renv.autoloader.running"), TRUE)) {
+    warning("ignoring recursive attempt to run renv autoloader")
+    return(invisible(TRUE))
+  }
+
+  # signal that we're loading renv during R startup
+  options(renv.autoloader.running = TRUE)
+  on.exit(options(renv.autoloader.running = NULL), add = TRUE)
+
+  # signal that we've consented to use renv
+  options(renv.consent = TRUE)
+
+  # load the 'utils' package eagerly -- this ensures that renv shims, which
+  # mask 'utils' packages, will come first on the search path
+  library(utils, lib.loc = .Library)
+
+  # unload renv if it's already been loaded
+  if ("renv" %in% loadedNamespaces())
+    unloadNamespace("renv")
+
+  # load bootstrap tools   
+  `%||%` <- function(x, y) {
+    if (is.null(x)) y else x
+  }
+  
+  catf <- function(fmt, ..., appendLF = TRUE) {
+  
+    quiet <- getOption("renv.bootstrap.quiet", default = FALSE)
+    if (quiet)
+      return(invisible())
+  
+    msg <- sprintf(fmt, ...)
+    cat(msg, file = stdout(), sep = if (appendLF) "\n" else "")
+  
+    invisible(msg)
+  
+  }
+  
+  header <- function(label,
+                     ...,
+                     prefix = "#",
+                     suffix = "-",
+                     n = min(getOption("width"), 78))
+  {
+    label <- sprintf(label, ...)
+    n <- max(n - nchar(label) - nchar(prefix) - 2L, 8L)
+    if (n <= 0)
+      return(paste(prefix, label))
+  
+    tail <- paste(rep.int(suffix, n), collapse = "")
+    paste0(prefix, " ", label, " ", tail)
+  
+  }
+  
+  heredoc <- function(text, leave = 0) {
+  
+    # remove leading, trailing whitespace
+    trimmed <- gsub("^\\s*\\n|\\n\\s*$", "", text)
+  
+    # split into lines
+    lines <- strsplit(trimmed, "\n", fixed = TRUE)[[1L]]
+  
+    # compute common indent
+    indent <- regexpr("[^[:space:]]", lines)
+    common <- min(setdiff(indent, -1L)) - leave
+    paste(substring(lines, common), collapse = "\n")
+  
+  }
+  
+  startswith <- function(string, prefix) {
+    substring(string, 1, nchar(prefix)) == prefix
+  }
+  
+  bootstrap <- function(version, library) {
+  
+    friendly <- renv_bootstrap_version_friendly(version)
+    section <- header(sprintf("Bootstrapping renv %s", friendly))
+    catf(section)
+  
+    # attempt to download renv
+    catf("- Downloading renv ... ", appendLF = FALSE)
+    withCallingHandlers(
+      tarball <- renv_bootstrap_download(version),
+      error = function(err) {
+        catf("FAILED")
+        stop("failed to download:\n", conditionMessage(err))
+      }
+    )
+    catf("OK")
+    on.exit(unlink(tarball), add = TRUE)
+  
+    # now attempt to install
+    catf("- Installing renv  ... ", appendLF = FALSE)
+    withCallingHandlers(
+      status <- renv_bootstrap_install(version, tarball, library),
+      error = function(err) {
+        catf("FAILED")
+        stop("failed to install:\n", conditionMessage(err))
+      }
+    )
+    catf("OK")
+  
+    # add empty line to break up bootstrapping from normal output
+    catf("")
+  
+    return(invisible())
+  }
+  
+  renv_bootstrap_tests_running <- function() {
+    getOption("renv.tests.running", default = FALSE)
+  }
+  
+  renv_bootstrap_repos <- function() {
+  
+    # get CRAN repository
+    cran <- getOption("renv.repos.cran", "https://cloud.r-project.org")
+  
+    # check for repos override
+    repos <- Sys.getenv("RENV_CONFIG_REPOS_OVERRIDE", unset = NA)
+    if (!is.na(repos)) {
+  
+      # check for RSPM; if set, use a fallback repository for renv
+      rspm <- Sys.getenv("RSPM", unset = NA)
+      if (identical(rspm, repos))
+        repos <- c(RSPM = rspm, CRAN = cran)
+  
+      return(repos)
+  
+    }
+  
+    # check for lockfile repositories
+    repos <- tryCatch(renv_bootstrap_repos_lockfile(), error = identity)
+    if (!inherits(repos, "error") && length(repos))
+      return(repos)
+  
+    # retrieve current repos
+    repos <- getOption("repos")
+  
+    # ensure @CRAN@ entries are resolved
+    repos[repos == "@CRAN@"] <- cran
+  
+    # add in renv.bootstrap.repos if set
+    default <- c(FALLBACK = "https://cloud.r-project.org")
+    extra <- getOption("renv.bootstrap.repos", default = default)
+    repos <- c(repos, extra)
+  
+    # remove duplicates that might've snuck in
+    dupes <- duplicated(repos) | duplicated(names(repos))
+    repos[!dupes]
+  
+  }
+  
+  renv_bootstrap_repos_lockfile <- function() {
+  
+    lockpath <- Sys.getenv("RENV_PATHS_LOCKFILE", unset = "renv.lock")
+    if (!file.exists(lockpath))
+      return(NULL)
+  
+    lockfile <- tryCatch(renv_json_read(lockpath), error = identity)
+    if (inherits(lockfile, "error")) {
+      warning(lockfile)
+      return(NULL)
+    }
+  
+    repos <- lockfile$R$Repositories
+    if (length(repos) == 0)
+      return(NULL)
+  
+    keys <- vapply(repos, `[[`, "Name", FUN.VALUE = character(1))
+    vals <- vapply(repos, `[[`, "URL", FUN.VALUE = character(1))
+    names(vals) <- keys
+  
+    return(vals)
+  
+  }
+  
+  renv_bootstrap_download <- function(version) {
+  
+    sha <- attr(version, "sha", exact = TRUE)
+  
+    methods <- if (!is.null(sha)) {
+  
+      # attempting to bootstrap a development version of renv
+      c(
+        function() renv_bootstrap_download_tarball(sha),
+        function() renv_bootstrap_download_github(sha)
+      )
+  
+    } else {
+  
+      # attempting to bootstrap a release version of renv
+      c(
+        function() renv_bootstrap_download_tarball(version),
+        function() renv_bootstrap_download_cran_latest(version),
+        function() renv_bootstrap_download_cran_archive(version)
+      )
+  
+    }
+  
+    for (method in methods) {
+      path <- tryCatch(method(), error = identity)
+      if (is.character(path) && file.exists(path))
+        return(path)
+    }
+  
+    stop("All download methods failed")
+  
+  }
+  
+  renv_bootstrap_download_impl <- function(url, destfile) {
+  
+    mode <- "wb"
+  
+    # https://bugs.r-project.org/bugzilla/show_bug.cgi?id=17715
+    fixup <-
+      Sys.info()[["sysname"]] == "Windows" &&
+      substring(url, 1L, 5L) == "file:"
+  
+    if (fixup)
+      mode <- "w+b"
+  
+    args <- list(
+      url      = url,
+      destfile = destfile,
+      mode     = mode,
+      quiet    = TRUE
+    )
+  
+    if ("headers" %in% names(formals(utils::download.file)))
+      args$headers <- renv_bootstrap_download_custom_headers(url)
+  
+    do.call(utils::download.file, args)
+  
+  }
+  
+  renv_bootstrap_download_custom_headers <- function(url) {
+  
+    headers <- getOption("renv.download.headers")
+    if (is.null(headers))
+      return(character())
+  
+    if (!is.function(headers))
+      stopf("'renv.download.headers' is not a function")
+  
+    headers <- headers(url)
+    if (length(headers) == 0L)
+      return(character())
+  
+    if (is.list(headers))
+      headers <- unlist(headers, recursive = FALSE, use.names = TRUE)
+  
+    ok <-
+      is.character(headers) &&
+      is.character(names(headers)) &&
+      all(nzchar(names(headers)))
+  
+    if (!ok)
+      stop("invocation of 'renv.download.headers' did not return a named character vector")
+  
+    headers
+  
+  }
+  
+  renv_bootstrap_download_cran_latest <- function(version) {
+  
+    spec <- renv_bootstrap_download_cran_latest_find(version)
+    type  <- spec$type
+    repos <- spec$repos
+  
+    baseurl <- utils::contrib.url(repos = repos, type = type)
+    ext <- if (identical(type, "source"))
+      ".tar.gz"
+    else if (Sys.info()[["sysname"]] == "Windows")
+      ".zip"
+    else
+      ".tgz"
+    name <- sprintf("renv_%s%s", version, ext)
+    url <- paste(baseurl, name, sep = "/")
+  
+    destfile <- file.path(tempdir(), name)
+    status <- tryCatch(
+      renv_bootstrap_download_impl(url, destfile),
+      condition = identity
+    )
+  
+    if (inherits(status, "condition"))
+      return(FALSE)
+  
+    # report success and return
+    destfile
+  
+  }
+  
+  renv_bootstrap_download_cran_latest_find <- function(version) {
+  
+    # check whether binaries are supported on this system
+    binary <-
+      getOption("renv.bootstrap.binary", default = TRUE) &&
+      !identical(.Platform$pkgType, "source") &&
+      !identical(getOption("pkgType"), "source") &&
+      Sys.info()[["sysname"]] %in% c("Darwin", "Windows")
+  
+    types <- c(if (binary) "binary", "source")
+  
+    # iterate over types + repositories
+    for (type in types) {
+      for (repos in renv_bootstrap_repos()) {
+  
+        # retrieve package database
+        db <- tryCatch(
+          as.data.frame(
+            utils::available.packages(type = type, repos = repos),
+            stringsAsFactors = FALSE
+          ),
+          error = identity
+        )
+  
+        if (inherits(db, "error"))
+          next
+  
+        # check for compatible entry
+        entry <- db[db$Package %in% "renv" & db$Version %in% version, ]
+        if (nrow(entry) == 0)
+          next
+  
+        # found it; return spec to caller
+        spec <- list(entry = entry, type = type, repos = repos)
+        return(spec)
+  
+      }
+    }
+  
+    # if we got here, we failed to find renv
+    fmt <- "renv %s is not available from your declared package repositories"
+    stop(sprintf(fmt, version))
+  
+  }
+  
+  renv_bootstrap_download_cran_archive <- function(version) {
+  
+    name <- sprintf("renv_%s.tar.gz", version)
+    repos <- renv_bootstrap_repos()
+    urls <- file.path(repos, "src/contrib/Archive/renv", name)
+    destfile <- file.path(tempdir(), name)
+  
+    for (url in urls) {
+  
+      status <- tryCatch(
+        renv_bootstrap_download_impl(url, destfile),
+        condition = identity
+      )
+  
+      if (identical(status, 0L))
+        return(destfile)
+  
+    }
+  
+    return(FALSE)
+  
+  }
+  
+  renv_bootstrap_download_tarball <- function(version) {
+  
+    # if the user has provided the path to a tarball via
+    # an environment variable, then use it
+    tarball <- Sys.getenv("RENV_BOOTSTRAP_TARBALL", unset = NA)
+    if (is.na(tarball))
+      return()
+  
+    # allow directories
+    if (dir.exists(tarball)) {
+      name <- sprintf("renv_%s.tar.gz", version)
+      tarball <- file.path(tarball, name)
+    }
+  
+    # bail if it doesn't exist
+    if (!file.exists(tarball)) {
+  
+      # let the user know we weren't able to honour their request
+      fmt <- "- RENV_BOOTSTRAP_TARBALL is set (%s) but does not exist."
+      msg <- sprintf(fmt, tarball)
+      warning(msg)
+  
+      # bail
+      return()
+  
+    }
+  
+    catf("- Using local tarball '%s'.", tarball)
+    tarball
+  
+  }
+  
+  renv_bootstrap_download_github <- function(version) {
+  
+    enabled <- Sys.getenv("RENV_BOOTSTRAP_FROM_GITHUB", unset = "TRUE")
+    if (!identical(enabled, "TRUE"))
+      return(FALSE)
+  
+    # prepare download options
+    pat <- Sys.getenv("GITHUB_PAT")
+    if (nzchar(Sys.which("curl")) && nzchar(pat)) {
+      fmt <- "--location --fail --header \"Authorization: token %s\""
+      extra <- sprintf(fmt, pat)
+      saved <- options("download.file.method", "download.file.extra")
+      options(download.file.method = "curl", download.file.extra = extra)
+      on.exit(do.call(base::options, saved), add = TRUE)
+    } else if (nzchar(Sys.which("wget")) && nzchar(pat)) {
+      fmt <- "--header=\"Authorization: token %s\""
+      extra <- sprintf(fmt, pat)
+      saved <- options("download.file.method", "download.file.extra")
+      options(download.file.method = "wget", download.file.extra = extra)
+      on.exit(do.call(base::options, saved), add = TRUE)
+    }
+  
+    url <- file.path("https://api.github.com/repos/rstudio/renv/tarball", version)
+    name <- sprintf("renv_%s.tar.gz", version)
+    destfile <- file.path(tempdir(), name)
+  
+    status <- tryCatch(
+      renv_bootstrap_download_impl(url, destfile),
+      condition = identity
+    )
+  
+    if (!identical(status, 0L))
+      return(FALSE)
+  
+    renv_bootstrap_download_augment(destfile)
+  
+    return(destfile)
+  
+  }
+  
+  # Add Sha to DESCRIPTION. This is stop gap until #890, after which we
+  # can use renv::install() to fully capture metadata.
+  renv_bootstrap_download_augment <- function(destfile) {
+    sha <- renv_bootstrap_git_extract_sha1_tar(destfile)
+    if (is.null(sha)) {
+      return()
+    }
+  
+    # Untar
+    tempdir <- tempfile("renv-github-")
+    on.exit(unlink(tempdir, recursive = TRUE), add = TRUE)
+    untar(destfile, exdir = tempdir)
+    pkgdir <- dir(tempdir, full.names = TRUE)[[1]]
+  
+    # Modify description
+    desc_path <- file.path(pkgdir, "DESCRIPTION")
+    desc_lines <- readLines(desc_path)
+    remotes_fields <- c(
+      "RemoteType: github",
+      "RemoteHost: api.github.com",
+      "RemoteRepo: renv",
+      "RemoteUsername: rstudio",
+      "RemotePkgRef: rstudio/renv",
+      paste("RemoteRef: ", sha),
+      paste("RemoteSha: ", sha)
+    )
+    writeLines(c(desc_lines[desc_lines != ""], remotes_fields), con = desc_path)
+  
+    # Re-tar
+    local({
+      old <- setwd(tempdir)
+      on.exit(setwd(old), add = TRUE)
+  
+      tar(destfile, compression = "gzip")
+    })
+    invisible()
+  }
+  
+  # Extract the commit hash from a git archive. Git archives include the SHA1
+  # hash as the comment field of the tarball pax extended header
+  # (see https://www.kernel.org/pub/software/scm/git/docs/git-archive.html)
+  # For GitHub archives this should be the first header after the default one
+  # (512 byte) header.
+  renv_bootstrap_git_extract_sha1_tar <- function(bundle) {
+  
+    # open the bundle for reading
+    # We use gzcon for everything because (from ?gzcon)
+    # > Reading from a connection which does not supply a 'gzip' magic
+    # > header is equivalent to reading from the original connection
+    conn <- gzcon(file(bundle, open = "rb", raw = TRUE))
+    on.exit(close(conn))
+  
+    # The default pax header is 512 bytes long and the first pax extended header
+    # with the comment should be 51 bytes long
+    # `52 comment=` (11 chars) + 40 byte SHA1 hash
+    len <- 0x200 + 0x33
+    res <- rawToChar(readBin(conn, "raw", n = len)[0x201:len])
+  
+    if (grepl("^52 comment=", res)) {
+      sub("52 comment=", "", res)
+    } else {
+      NULL
+    }
+  }
+  
+  renv_bootstrap_install <- function(version, tarball, library) {
+  
+    # attempt to install it into project library
+    dir.create(library, showWarnings = FALSE, recursive = TRUE)
+    output <- renv_bootstrap_install_impl(library, tarball)
+  
+    # check for successful install
+    status <- attr(output, "status")
+    if (is.null(status) || identical(status, 0L))
+      return(status)
+  
+    # an error occurred; report it
+    header <- "installation of renv failed"
+    lines <- paste(rep.int("=", nchar(header)), collapse = "")
+    text <- paste(c(header, lines, output), collapse = "\n")
+    stop(text)
+  
+  }
+  
+  renv_bootstrap_install_impl <- function(library, tarball) {
+  
+    # invoke using system2 so we can capture and report output
+    bin <- R.home("bin")
+    exe <- if (Sys.info()[["sysname"]] == "Windows") "R.exe" else "R"
+    R <- file.path(bin, exe)
+  
+    args <- c(
+      "--vanilla", "CMD", "INSTALL", "--no-multiarch",
+      "-l", shQuote(path.expand(library)),
+      shQuote(path.expand(tarball))
+    )
+  
+    system2(R, args, stdout = TRUE, stderr = TRUE)
+  
+  }
+  
+  renv_bootstrap_platform_prefix <- function() {
+  
+    # construct version prefix
+    version <- paste(R.version$major, R.version$minor, sep = ".")
+    prefix <- paste("R", numeric_version(version)[1, 1:2], sep = "-")
+  
+    # include SVN revision for development versions of R
+    # (to avoid sharing platform-specific artefacts with released versions of R)
+    devel <-
+      identical(R.version[["status"]],   "Under development (unstable)") ||
+      identical(R.version[["nickname"]], "Unsuffered Consequences")
+  
+    if (devel)
+      prefix <- paste(prefix, R.version[["svn rev"]], sep = "-r")
+  
+    # build list of path components
+    components <- c(prefix, R.version$platform)
+  
+    # include prefix if provided by user
+    prefix <- renv_bootstrap_platform_prefix_impl()
+    if (!is.na(prefix) && nzchar(prefix))
+      components <- c(prefix, components)
+  
+    # build prefix
+    paste(components, collapse = "/")
+  
+  }
+  
+  renv_bootstrap_platform_prefix_impl <- function() {
+  
+    # if an explicit prefix has been supplied, use it
+    prefix <- Sys.getenv("RENV_PATHS_PREFIX", unset = NA)
+    if (!is.na(prefix))
+      return(prefix)
+  
+    # if the user has requested an automatic prefix, generate it
+    auto <- Sys.getenv("RENV_PATHS_PREFIX_AUTO", unset = NA)
+    if (is.na(auto) && getRversion() >= "4.4.0")
+      auto <- "TRUE"
+  
+    if (auto %in% c("TRUE", "True", "true", "1"))
+      return(renv_bootstrap_platform_prefix_auto())
+  
+    # empty string on failure
+    ""
+  
+  }
+  
+  renv_bootstrap_platform_prefix_auto <- function() {
+  
+    prefix <- tryCatch(renv_bootstrap_platform_os(), error = identity)
+    if (inherits(prefix, "error") || prefix %in% "unknown") {
+  
+      msg <- paste(
+        "failed to infer current operating system",
+        "please file a bug report at https://github.com/rstudio/renv/issues",
+        sep = "; "
+      )
+  
+      warning(msg)
+  
+    }
+  
+    prefix
+  
+  }
+  
+  renv_bootstrap_platform_os <- function() {
+  
+    sysinfo <- Sys.info()
+    sysname <- sysinfo[["sysname"]]
+  
+    # handle Windows + macOS up front
+    if (sysname == "Windows")
+      return("windows")
+    else if (sysname == "Darwin")
+      return("macos")
+  
+    # check for os-release files
+    for (file in c("/etc/os-release", "/usr/lib/os-release"))
+      if (file.exists(file))
+        return(renv_bootstrap_platform_os_via_os_release(file, sysinfo))
+  
+    # check for redhat-release files
+    if (file.exists("/etc/redhat-release"))
+      return(renv_bootstrap_platform_os_via_redhat_release())
+  
+    "unknown"
+  
+  }
+  
+  renv_bootstrap_platform_os_via_os_release <- function(file, sysinfo) {
+  
+    # read /etc/os-release
+    release <- utils::read.table(
+      file             = file,
+      sep              = "=",
+      quote            = c("\"", "'"),
+      col.names        = c("Key", "Value"),
+      comment.char     = "#",
+      stringsAsFactors = FALSE
+    )
+  
+    vars <- as.list(release$Value)
+    names(vars) <- release$Key
+  
+    # get os name
+    os <- tolower(sysinfo[["sysname"]])
+  
+    # read id
+    id <- "unknown"
+    for (field in c("ID", "ID_LIKE")) {
+      if (field %in% names(vars) && nzchar(vars[[field]])) {
+        id <- vars[[field]]
+        break
+      }
+    }
+  
+    # read version
+    version <- "unknown"
+    for (field in c("UBUNTU_CODENAME", "VERSION_CODENAME", "VERSION_ID", "BUILD_ID")) {
+      if (field %in% names(vars) && nzchar(vars[[field]])) {
+        version <- vars[[field]]
+        break
+      }
+    }
+  
+    # join together
+    paste(c(os, id, version), collapse = "-")
+  
+  }
+  
+  renv_bootstrap_platform_os_via_redhat_release <- function() {
+  
+    # read /etc/redhat-release
+    contents <- readLines("/etc/redhat-release", warn = FALSE)
+  
+    # infer id
+    id <- if (grepl("centos", contents, ignore.case = TRUE))
+      "centos"
+    else if (grepl("redhat", contents, ignore.case = TRUE))
+      "redhat"
+    else
+      "unknown"
+  
+    # try to find a version component (very hacky)
+    version <- "unknown"
+  
+    parts <- strsplit(contents, "[[:space:]]")[[1L]]
+    for (part in parts) {
+  
+      nv <- tryCatch(numeric_version(part), error = identity)
+      if (inherits(nv, "error"))
+        next
+  
+      version <- nv[1, 1]
+      break
+  
+    }
+  
+    paste(c("linux", id, version), collapse = "-")
+  
+  }
+  
+  renv_bootstrap_library_root_name <- function(project) {
+  
+    # use project name as-is if requested
+    asis <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT_ASIS", unset = "FALSE")
+    if (asis)
+      return(basename(project))
+  
+    # otherwise, disambiguate based on project's path
+    id <- substring(renv_bootstrap_hash_text(project), 1L, 8L)
+    paste(basename(project), id, sep = "-")
+  
+  }
+  
+  renv_bootstrap_library_root <- function(project) {
+  
+    prefix <- renv_bootstrap_profile_prefix()
+  
+    path <- Sys.getenv("RENV_PATHS_LIBRARY", unset = NA)
+    if (!is.na(path))
+      return(paste(c(path, prefix), collapse = "/"))
+  
+    path <- renv_bootstrap_library_root_impl(project)
+    if (!is.null(path)) {
+      name <- renv_bootstrap_library_root_name(project)
+      return(paste(c(path, prefix, name), collapse = "/"))
+    }
+  
+    renv_bootstrap_paths_renv("library", project = project)
+  
+  }
+  
+  renv_bootstrap_library_root_impl <- function(project) {
+  
+    root <- Sys.getenv("RENV_PATHS_LIBRARY_ROOT", unset = NA)
+    if (!is.na(root))
+      return(root)
+  
+    type <- renv_bootstrap_project_type(project)
+    if (identical(type, "package")) {
+      userdir <- renv_bootstrap_user_dir()
+      return(file.path(userdir, "library"))
+    }
+  
+  }
+  
+  renv_bootstrap_validate_version <- function(version, description = NULL) {
+  
+    # resolve description file
+    #
+    # avoid passing lib.loc to `packageDescription()` below, since R will
+    # use the loaded version of the package by default anyhow. note that
+    # this function should only be called after 'renv' is loaded
+    # https://github.com/rstudio/renv/issues/1625
+    description <- description %||% packageDescription("renv")
+  
+    # check whether requested version 'version' matches loaded version of renv
+    sha <- attr(version, "sha", exact = TRUE)
+    valid <- if (!is.null(sha))
+      renv_bootstrap_validate_version_dev(sha, description)
+    else
+      renv_bootstrap_validate_version_release(version, description)
+  
+    if (valid)
+      return(TRUE)
+  
+    # the loaded version of renv doesn't match the requested version;
+    # give the user instructions on how to proceed
+    dev <- identical(description[["RemoteType"]], "github")
+    remote <- if (dev)
+      paste("rstudio/renv", description[["RemoteSha"]], sep = "@")
+    else
+      paste("renv", description[["Version"]], sep = "@")
+  
+    # display both loaded version + sha if available
+    friendly <- renv_bootstrap_version_friendly(
+      version = description[["Version"]],
+      sha     = if (dev) description[["RemoteSha"]]
+    )
+  
+    fmt <- heredoc("
+      renv %1$s was loaded from project library, but this project is configured to use renv %2$s.
+      - Use `renv::record(\"%3$s\")` to record renv %1$s in the lockfile.
+      - Use `renv::restore(packages = \"renv\")` to install renv %2$s into the project library.
+    ")
+    catf(fmt, friendly, renv_bootstrap_version_friendly(version), remote)
+  
+    FALSE
+  
+  }
+  
+  renv_bootstrap_validate_version_dev <- function(version, description) {
+    expected <- description[["RemoteSha"]]
+    is.character(expected) && startswith(expected, version)
+  }
+  
+  renv_bootstrap_validate_version_release <- function(version, description) {
+    expected <- description[["Version"]]
+    is.character(expected) && identical(expected, version)
+  }
+  
+  renv_bootstrap_hash_text <- function(text) {
+  
+    hashfile <- tempfile("renv-hash-")
+    on.exit(unlink(hashfile), add = TRUE)
+  
+    writeLines(text, con = hashfile)
+    tools::md5sum(hashfile)
+  
+  }
+  
+  renv_bootstrap_load <- function(project, libpath, version) {
+  
+    # try to load renv from the project library
+    if (!requireNamespace("renv", lib.loc = libpath, quietly = TRUE))
+      return(FALSE)
+  
+    # warn if the version of renv loaded does not match
+    renv_bootstrap_validate_version(version)
+  
+    # execute renv load hooks, if any
+    hooks <- getHook("renv::autoload")
+    for (hook in hooks)
+      if (is.function(hook))
+        tryCatch(hook(), error = warnify)
+  
+    # load the project
+    renv::load(project)
+  
+    TRUE
+  
+  }
+  
+  renv_bootstrap_profile_load <- function(project) {
+  
+    # if RENV_PROFILE is already set, just use that
+    profile <- Sys.getenv("RENV_PROFILE", unset = NA)
+    if (!is.na(profile) && nzchar(profile))
+      return(profile)
+  
+    # check for a profile file (nothing to do if it doesn't exist)
+    path <- renv_bootstrap_paths_renv("profile", profile = FALSE, project = project)
+    if (!file.exists(path))
+      return(NULL)
+  
+    # read the profile, and set it if it exists
+    contents <- readLines(path, warn = FALSE)
+    if (length(contents) == 0L)
+      return(NULL)
+  
+    # set RENV_PROFILE
+    profile <- contents[[1L]]
+    if (!profile %in% c("", "default"))
+      Sys.setenv(RENV_PROFILE = profile)
+  
+    profile
+  
+  }
+  
+  renv_bootstrap_profile_prefix <- function() {
+    profile <- renv_bootstrap_profile_get()
+    if (!is.null(profile))
+      return(file.path("profiles", profile, "renv"))
+  }
+  
+  renv_bootstrap_profile_get <- function() {
+    profile <- Sys.getenv("RENV_PROFILE", unset = "")
+    renv_bootstrap_profile_normalize(profile)
+  }
+  
+  renv_bootstrap_profile_set <- function(profile) {
+    profile <- renv_bootstrap_profile_normalize(profile)
+    if (is.null(profile))
+      Sys.unsetenv("RENV_PROFILE")
+    else
+      Sys.setenv(RENV_PROFILE = profile)
+  }
+  
+  renv_bootstrap_profile_normalize <- function(profile) {
+  
+    if (is.null(profile) || profile %in% c("", "default"))
+      return(NULL)
+  
+    profile
+  
+  }
+  
+  renv_bootstrap_path_absolute <- function(path) {
+  
+    substr(path, 1L, 1L) %in% c("~", "/", "\\") || (
+      substr(path, 1L, 1L) %in% c(letters, LETTERS) &&
+      substr(path, 2L, 3L) %in% c(":/", ":\\")
+    )
+  
+  }
+  
+  renv_bootstrap_paths_renv <- function(..., profile = TRUE, project = NULL) {
+    renv <- Sys.getenv("RENV_PATHS_RENV", unset = "renv")
+    root <- if (renv_bootstrap_path_absolute(renv)) NULL else project
+    prefix <- if (profile) renv_bootstrap_profile_prefix()
+    components <- c(root, renv, prefix, ...)
+    paste(components, collapse = "/")
+  }
+  
+  renv_bootstrap_project_type <- function(path) {
+  
+    descpath <- file.path(path, "DESCRIPTION")
+    if (!file.exists(descpath))
+      return("unknown")
+  
+    desc <- tryCatch(
+      read.dcf(descpath, all = TRUE),
+      error = identity
+    )
+  
+    if (inherits(desc, "error"))
+      return("unknown")
+  
+    type <- desc$Type
+    if (!is.null(type))
+      return(tolower(type))
+  
+    package <- desc$Package
+    if (!is.null(package))
+      return("package")
+  
+    "unknown"
+  
+  }
+  
+  renv_bootstrap_user_dir <- function() {
+    dir <- renv_bootstrap_user_dir_impl()
+    path.expand(chartr("\\", "/", dir))
+  }
+  
+  renv_bootstrap_user_dir_impl <- function() {
+  
+    # use local override if set
+    override <- getOption("renv.userdir.override")
+    if (!is.null(override))
+      return(override)
+  
+    # use R_user_dir if available
+    tools <- asNamespace("tools")
+    if (is.function(tools$R_user_dir))
+      return(tools$R_user_dir("renv", "cache"))
+  
+    # try using our own backfill for older versions of R
+    envvars <- c("R_USER_CACHE_DIR", "XDG_CACHE_HOME")
+    for (envvar in envvars) {
+      root <- Sys.getenv(envvar, unset = NA)
+      if (!is.na(root))
+        return(file.path(root, "R/renv"))
+    }
+  
+    # use platform-specific default fallbacks
+    if (Sys.info()[["sysname"]] == "Windows")
+      file.path(Sys.getenv("LOCALAPPDATA"), "R/cache/R/renv")
+    else if (Sys.info()[["sysname"]] == "Darwin")
+      "~/Library/Caches/org.R-project.R/R/renv"
+    else
+      "~/.cache/R/renv"
+  
+  }
+  
+  renv_bootstrap_version_friendly <- function(version, shafmt = NULL, sha = NULL) {
+    sha <- sha %||% attr(version, "sha", exact = TRUE)
+    parts <- c(version, sprintf(shafmt %||% " [sha: %s]", substring(sha, 1L, 7L)))
+    paste(parts, collapse = "")
+  }
+  
+  renv_bootstrap_exec <- function(project, libpath, version) {
+    if (!renv_bootstrap_load(project, libpath, version))
+      renv_bootstrap_run(version, libpath)
+  }
+  
+  renv_bootstrap_run <- function(version, libpath) {
+  
+    # perform bootstrap
+    bootstrap(version, libpath)
+  
+    # exit early if we're just testing bootstrap
+    if (!is.na(Sys.getenv("RENV_BOOTSTRAP_INSTALL_ONLY", unset = NA)))
+      return(TRUE)
+  
+    # try again to load
+    if (requireNamespace("renv", lib.loc = libpath, quietly = TRUE)) {
+      return(renv::load(project = getwd()))
+    }
+  
+    # failed to download or load renv; warn the user
+    msg <- c(
+      "Failed to find an renv installation: the project will not be loaded.",
+      "Use `renv::activate()` to re-initialize the project."
+    )
+  
+    warning(paste(msg, collapse = "\n"), call. = FALSE)
+  
+  }
+  
+  renv_json_read <- function(file = NULL, text = NULL) {
+  
+    jlerr <- NULL
+  
+    # if jsonlite is loaded, use that instead
+    if ("jsonlite" %in% loadedNamespaces()) {
+  
+      json <- tryCatch(renv_json_read_jsonlite(file, text), error = identity)
+      if (!inherits(json, "error"))
+        return(json)
+  
+      jlerr <- json
+  
+    }
+  
+    # otherwise, fall back to the default JSON reader
+    json <- tryCatch(renv_json_read_default(file, text), error = identity)
+    if (!inherits(json, "error"))
+      return(json)
+  
+    # report an error
+    if (!is.null(jlerr))
+      stop(jlerr)
+    else
+      stop(json)
+  
+  }
+  
+  renv_json_read_jsonlite <- function(file = NULL, text = NULL) {
+    text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n")
+    jsonlite::fromJSON(txt = text, simplifyVector = FALSE)
+  }
+  
+  renv_json_read_default <- function(file = NULL, text = NULL) {
+  
+    # find strings in the JSON
+    text <- paste(text %||% readLines(file, warn = FALSE), collapse = "\n")
+    pattern <- '["](?:(?:\\\\.)|(?:[^"\\\\]))*?["]'
+    locs <- gregexpr(pattern, text, perl = TRUE)[[1]]
+  
+    # if any are found, replace them with placeholders
+    replaced <- text
+    strings <- character()
+    replacements <- character()
+  
+    if (!identical(c(locs), -1L)) {
+  
+      # get the string values
+      starts <- locs
+      ends <- locs + attr(locs, "match.length") - 1L
+      strings <- substring(text, starts, ends)
+  
+      # only keep those requiring escaping
+      strings <- grep("[[\\]{}:]", strings, perl = TRUE, value = TRUE)
+  
+      # compute replacements
+      replacements <- sprintf('"\032%i\032"', seq_along(strings))
+  
+      # replace the strings
+      mapply(function(string, replacement) {
+        replaced <<- sub(string, replacement, replaced, fixed = TRUE)
+      }, strings, replacements)
+  
+    }
+  
+    # transform the JSON into something the R parser understands
+    transformed <- replaced
+    transformed <- gsub("{}", "`names<-`(list(), character())", transformed, fixed = TRUE)
+    transformed <- gsub("[[{]", "list(", transformed, perl = TRUE)
+    transformed <- gsub("[]}]", ")", transformed, perl = TRUE)
+    transformed <- gsub(":", "=", transformed, fixed = TRUE)
+    text <- paste(transformed, collapse = "\n")
+  
+    # parse it
+    json <- parse(text = text, keep.source = FALSE, srcfile = NULL)[[1L]]
+  
+    # construct map between source strings, replaced strings
+    map <- as.character(parse(text = strings))
+    names(map) <- as.character(parse(text = replacements))
+  
+    # convert to list
+    map <- as.list(map)
+  
+    # remap strings in object
+    remapped <- renv_json_read_remap(json, map)
+  
+    # evaluate
+    eval(remapped, envir = baseenv())
+  
+  }
+  
+  renv_json_read_remap <- function(json, map) {
+  
+    # fix names
+    if (!is.null(names(json))) {
+      lhs <- match(names(json), names(map), nomatch = 0L)
+      rhs <- match(names(map), names(json), nomatch = 0L)
+      names(json)[rhs] <- map[lhs]
+    }
+  
+    # fix values
+    if (is.character(json))
+      return(map[[json]] %||% json)
+  
+    # handle true, false, null
+    if (is.name(json)) {
+      text <- as.character(json)
+      if (text == "true")
+        return(TRUE)
+      else if (text == "false")
+        return(FALSE)
+      else if (text == "null")
+        return(NULL)
+    }
+  
+    # recurse
+    if (is.recursive(json)) {
+      for (i in seq_along(json)) {
+        json[i] <- list(renv_json_read_remap(json[[i]], map))
+      }
+    }
+  
+    json
+  
+  }
+
+  # load the renv profile, if any
+  renv_bootstrap_profile_load(project)
+
+  # construct path to library root
+  root <- renv_bootstrap_library_root(project)
+
+  # construct library prefix for platform
+  prefix <- renv_bootstrap_platform_prefix()
+
+  # construct full libpath
+  libpath <- file.path(root, prefix)
+
+  # run bootstrap code
+  renv_bootstrap_exec(project, libpath, version)
+
+  invisible()
+
+})
diff --git a/renv/settings.json b/renv/settings.json
new file mode 100644
index 0000000000000000000000000000000000000000..ffdbb3200f779343ad1aa1a2fb6c74a02fd9b365
--- /dev/null
+++ b/renv/settings.json
@@ -0,0 +1,19 @@
+{
+  "bioconductor.version": null,
+  "external.libraries": [],
+  "ignored.packages": [],
+  "package.dependency.fields": [
+    "Imports",
+    "Depends",
+    "LinkingTo"
+  ],
+  "ppm.enabled": null,
+  "ppm.ignored.urls": [],
+  "r.version": null,
+  "snapshot.type": "implicit",
+  "use.cache": true,
+  "vcs.ignore.cellar": true,
+  "vcs.ignore.library": true,
+  "vcs.ignore.local": true,
+  "vcs.manage.ignores": true
+}
diff --git a/scripts/draw.R b/scripts/draw.R
new file mode 100644
index 0000000000000000000000000000000000000000..78e48360ef468128f8e0abe2aeaf954366150c1e
--- /dev/null
+++ b/scripts/draw.R
@@ -0,0 +1,67 @@
+####
+# Plotting functions.
+####
+
+# global imports
+import::here("dplyr", "filter", .character_only = TRUE)
+import::here(
+  "ggplot2",
+  c(
+    "aes", "facet_wrap", "geom_histogram", "geom_raster", "ggplot",
+    "scale_x_continuous", "scale_y_continuous", "scale_fill_viridis_c"
+  ),
+  .character_only = TRUE
+)
+import::here("magrittr", "%>%", .character_only = TRUE)
+
+# local import
+import::here("image.R", "img_to_df", .character_only = TRUE)
+
+
+#' Channel histogram
+#'
+#' Histogram ggplot of each color channel of an image.
+#'
+#' @param img an imager::cimg
+#' @param cc a character vector, subset of ("R", "G", "B").
+#'
+#' @returns a ggplot2 graph
+channel_hist <- function(img, cc = c("R", "G", "B")) {
+  if (all(cc %in% c("R", "G", "B"))) {
+    gg <- img %>%
+      img_to_df() %>%
+      filter(channel %in% cc) %>%
+      ggplot(aes(value, col = channel)) +
+      geom_histogram(bins = 30L) +
+      facet_wrap(~channel)
+  } else {
+    stop("Invalid 'cc' argument.")
+  }
+  return(gg)
+}
+
+
+#' Raster channel
+#'
+#' Raster ggplot of an image channel.
+#'
+#' @param img an imager::cimg
+#' @param cc character, specify the image channel, one of "R", "G", "B".
+#'
+#' @returns a ggplot2 graph
+raster_channel <- function(img, cc = NULL) {
+  if (cc %in% c("R", "G", "B")) {
+    gg <- img %>%
+      img_to_df() %>%
+      filter(channel == cc) %>%
+      ggplot(aes(x, y, fill = value)) +
+      geom_raster() +
+      scale_x_continuous(expand = c(0L, 0L)) +
+      scale_y_continuous(expand = c(0L, 0L), trans = scales::reverse_trans()) +
+      scale_fill_viridis_c(direction = 1L, option = "plasma")
+  } else {
+    stop("Invalid 'cc' argument.")
+  }
+
+  return(gg)
+}
diff --git a/scripts/image.R b/scripts/image.R
new file mode 100644
index 0000000000000000000000000000000000000000..60cd445ad928de5b55c7a5a2d2a0283660efe175
--- /dev/null
+++ b/scripts/image.R
@@ -0,0 +1,147 @@
+####
+# Utility functions for imager::cimg objects.
+####
+
+# global imports
+import::here("dplyr", c("mutate", "sample_n"), .character_only = TRUE)
+import::here(
+  "imager",
+  c("grayscale", "HSVtoRGB", "imappend", "imsplit", "map_il", "RGBtoHSV"),
+  .character_only = TRUE
+)
+import::here("magrittr", "%>%", .character_only = TRUE)
+import::here("mclust", "densityMclust", .character_only = TRUE)
+import::here("purrr", "modify_at", .character_only = TRUE)
+
+# local imports
+import::here("utils.R", "sample_histogram", .character_only = TRUE)
+
+
+#' DataFrame conversion
+#'
+#' Convert image to dataframe and expose color channel.
+#'
+#' @param img an imager::cimg
+#'
+#' @returns a data.frame
+img_to_df <- function(img) {
+  out <- img %>%
+    as.data.frame() %>%
+    mutate(channel = factor(cc, labels = c("R", "G", "B")))
+  return(out)
+}
+
+
+#' Histogram equalization
+#'
+#' Flatten histogram by replacing the pixel value of an image by their rank.
+#'
+#' @param img an imager::cimg
+#'
+#' @returns an imager::cimg
+hist_eq <- function(img) {
+  return(as.cimg(ecdf(img)(img), dim = dim(img)))
+}
+
+
+#' Enhance contrast
+#'
+#' Enhance the contrasts of an image by running an histogram equalization
+#' separately on each channel and combining the results.
+#'
+#' @param img an imager::cimg
+#'
+#' @returns an imager::cimg
+enhance_contrast <- function(img) {
+  out <- img %>%
+    imsplit("cc") %>%
+    map_il(hist_eq) %>%
+    imappend("cc")
+  return(out)
+}
+
+
+#' Reduce saturation
+#'
+#' Reduce the saturation of an image through HSV conversion.
+#'
+#' @param img an imager::cimg
+#' @param ratio an integer, how much to divide the saturation by.
+#'
+#' @returns an imager::cimg
+desaturation <- function(img, ratio = 2L) {
+  out <- img %>%
+    RGBtoHSV() %>%
+    imsplit("cc") %>%
+    modify_at(2L, ~ . / ratio) %>%
+    imappend("cc") %>%
+    HSVtoRGB()
+  return(out)
+}
+
+
+#' Correct illumination
+#'
+#' Correct a gray-scaled image illumination by fitting a linear model and
+#' removing the spatial trend.
+#'
+#' @param img an imager::cimg
+#' @param nsamples an integer, pixel subsampling value.
+#'
+#' @returns an imager::cimg object
+correct_illumination <- function(img, nsamples = 1e4L) {
+  # convert to grayscale if needed
+  if (rev(dim(img))[1L] > 1L) {
+    img <- grayscale(img)
+  }
+  # linear regression trend
+  trend <- img %>%
+    as.data.frame() %>%
+    sample_n(nsamples) %>%
+    lm(value ~ x * y, data = .) %>%
+    predict(img)
+  out <- img - trend
+  return(out)
+}
+
+
+#' Invert grayscale image
+#'
+#' @param img an imager::cimg
+#'
+#' @returns an imager::cimg
+invert_grayscale <- function(img) {
+  # convert to grayscale if needed
+  if (rev(dim(img))[1L] > 1L) {
+    img <- grayscale(img)
+  }
+  out <- max(img) - img
+  return(out)
+}
+
+
+#' Binarize
+#'
+#' Binarize a grayscale image.
+#'
+#' @param img an imager::cimg
+#' @param quantile a real, the quantile level used for thresholding.
+#' @param ... mclust::densityMclust parameters
+#'
+#' @returns an imager::cimg
+binarize <- function(img, quantile = 0.95, ...) {
+  # convert to grayscale if needed and invert
+  if (rev(dim(img))[1L] > 1L) {
+    stop("A grayscale image is expected.")
+  }
+  # sample 
+  sample <- sample_histogram(img)
+  # fit Gaussian mixture
+  gm <- densityMclust(sample, G = 1L, plot = FALSE, ...)
+  # threshold based on 95% quantile
+  threshold <- qnorm(
+    quantile, gm$parameters$mean[1L], sqrt(gm$parameters$variance$sigmasq[1L])
+  )
+  out <- img > threshold
+  return(out)
+}
diff --git a/scripts/main.R b/scripts/main.R
new file mode 100644
index 0000000000000000000000000000000000000000..e76dc1c8fcb31b33fc87a4dc529da1e288df08e6
--- /dev/null
+++ b/scripts/main.R
@@ -0,0 +1,241 @@
+####
+# Test steps on single image
+####
+
+# global imports
+import::from("cluster", "ellipsoidhull", .character_only = TRUE)
+import::from(
+  "imager",
+  c("grabRect", "grayscale", "grow", "load.image", "shrink",
+    "split_connected", "%inr%"),
+  .character_only = TRUE
+)
+import::from("magrittr", c("%>%", "%$%"), .character_only = TRUE)
+import::from("mclust", "densityMclust", .character_only = TRUE)
+import::from("Momocs", c("import_jpg", "Out"), .character_only = TRUE)
+import::from("purrr", c("discard", "keep", "map"), .character_only = TRUE)
+
+# local imports
+import::from(
+  file.path("scripts", "image.R"),
+  c("binarize", "correct_illumination", "invert_grayscale"),
+  .character_only = TRUE
+)
+import::from(
+  file.path("scripts", "pixset.R"),
+  c("combine", "intersect_borders", "combine_bis", "rotation_angle"),
+  .character_only = TRUE
+)
+import::from(
+  file.path("scripts", "utils.R"),
+  c("ehull", "cm_to_pixel"),
+  .character_only = TRUE
+)
+
+
+# Load image -------------------------------------------------------------------
+
+fpath <- file.path("data", "Example", "DSC_6177.JPG")
+img <- load.image(fpath)
+
+
+# Remove tray edges manually ---------------------------------------------------
+
+# try to isolate tray edges
+img_reduc <- grabRect(img, output = "im")
+
+
+# Binarize ---------------------------------------------------------------------
+
+# convert to grayscale
+img_gr <- grayscale(img_reduc)
+plot(img_gr, main = "grayscale")
+
+# correct illumination and invert grayscale
+img_gr_inv <- img_gr %>%
+  correct_illumination() %>%
+  invert_grayscale()
+plot(img_gr_inv, main = "grayscale inverted")
+
+# binarize (alternative is to use the imager::threshold function)
+img_pix <- binarize(img_gr_inv, quantile = 0.95)
+plot(img_pix, main = "binarized")
+
+
+# Extract connected components and ruler ---------------------------------------
+
+set_pix <- img_pix %>%
+  # small dilatation to improve components detection
+  grow(2L) %>%
+  # extract components
+  split_connected(., high_connectivity = TRUE)
+
+# there are 3 kind of components: small disconnected bits (antenna, legs),
+# medium (geriss), one large (ruler)
+# fit mixture of gaussian distribution with 2 comps
+set_size <- set_pix %>% lapply(sum) %>% unlist()
+gm <- densityMclust(log10(set_size), G = 2L, plot = FALSE)
+thresholds <- qnorm(
+  c(5e-3, 1L - 5e-3),
+  gm$parameters$mean[2L],
+  sqrt(rev(gm$parameters$variance$sigmasq)[1L])
+)
+plot(gm, what = "density", data = log10(set_size), breaks = 200L)
+abline(v = thresholds, col = "purple", lty = 2L)
+
+# split components then shrink back
+set_pix_small <- set_pix %>%
+  keep(~ sum(.) %inr% c(2L, 10L**thresholds[1L])) %>%
+  map(shrink, 2L)
+set_pix_med <- set_pix %>%
+  keep(~ (sum(.) %inr% 10L**thresholds)) %>%
+  map(shrink, 2L)
+ruler <- set_pix %>%
+  keep((~ sum(.) > 10L**thresholds[2L])) %>%
+  map(shrink, 2L)
+rm(set_pix)
+
+# remove pixsets that intersect the image borders
+set_pix_med <- set_pix_med %>%
+  discard(~ intersect_borders(., img_gr))
+
+# plot first medium component
+set_pix_med[[1L]] %>%
+  imager::autocrop() %>%
+  imager::pad(50L, "xy") %>%
+  plot(main = "first medium component")
+
+
+# Re-attach small component to medium ones -------------------------------------
+
+set_pix_med <- set_pix_med %>%
+  map(~ combine(., set_pix_small))
+rm(set_pix_small)
+
+set_pix_med[[1L]] %>%
+  imager::autocrop() %>%
+  imager::pad(50L, "xy") %>%
+  plot(main = "re-attached first component")
+
+
+# Get conversion factor from ruler  --------------------------------------------
+
+# get the conversion factor cm to pixel
+conv_factor <- cm_to_pixel(ruler[[1L]])
+
+ruler_ct <- ruler[[1L]] %>%
+  imager::contours(nlevels = 1L)
+plot(
+  ruler[[1L]], main = "ruler", xlim = c(1200L, 1800L), ylim = c(1500L, 1000L)
+)
+ruler_ct[[1L]] %$% {points(x, y, col = "red")}
+ruler_ct[[3L]] %$% {points(x, y, col = "green")}
+
+
+# Split superimposed components  -----------------------------------------------
+
+# TODO create function that split connected pixset using morphological opening
+# illustration with two elements
+
+# morphological opening (erosion o dilation) to isolate abdomen
+pixset_l <- set_pix_med[[4L]] %>%
+  imager::mopening_square(6L) %>%
+  imager::split_connected(high_connectivity = TRUE) %>%
+  keep(~ (sum(.) > 1e2L))
+plot(
+  pixset_l %>% imager::parany() %>%
+    imager::autocrop() %>% imager::pad(50L, "xy")
+)
+
+# further split apart the remaining parts
+pixset_part <- (set_pix_med[[4L]] - pixset_l %>% imager::parany()) %>%
+  imager::split_connected(high_connectivity = TRUE)
+plot(
+  (set_pix_med[[4L]] - pixset_l %>% imager::parany()) %>%
+    imager::autocrop() %>% imager::pad(50L, "xy")
+)
+
+# try to re-attach parts to each component
+pixset_l <- pixset_l %>%
+  map(~ combine_bis(., pixset_part, padding = 25L))
+rm(pixset_part)
+plot(pixset_l[[1L]] %>% imager::autocrop() %>% imager::pad(50L, "xy"))
+plot(pixset_l[[2L]] %>% imager::autocrop() %>% imager::pad(50L, "xy"))
+
+# TODO better alternative: fit an ellipsoid hull around each abdomen (also
+# increase both semi-axis length) then check which part satisfy the ellipsoid
+# equation -> re-attach back if so
+
+
+# Rotate along x-axis ----------------------------------------------------------
+
+# get the rotation angle along x-axis
+theta <- rotation_angle(set_pix_med[[1L]], size = 6L)
+eh <- ehull(set_pix_med[[1L]], 6L)
+
+set_pix_med[[1L]] %>%
+  plot(main = "ellipsoid hull", xlim = c(800L, 950L), ylim = c(150L, 1L))
+set_pix_med[[1L]] %>%
+  imager::mopening_square(6L) %>% imager::as.pixset() %>% imager::where() %$%
+  {points(x, y, cex = 0.25, col = "green")}
+lines(predict(eh), col = "red")
+
+# rotated pixset
+pixset %>%
+  imager::imrotate(theta * 180L / pi, ehull$loc[1L], ehull$loc[2L], 1L) %>%
+  plot(main = "rotated", xlim = c(800L, 950L), ylim = c(150L, 1L))
+
+
+# TODO: Need to further orientate the head towards the y-axis (±pi/2).
+
+
+# Export component and ruler to JPG (for Momocs) -------------------------------
+
+# create temp directory
+tmp_dir <- file.path("data", "tmp")
+dir.create(tmp_dir, showWarnings = FALSE)
+# export component
+for (idx in seq_along(set_pix_med)) {
+  set_pix_med[[idx]] %>%
+    imager::autocrop(.) %>%
+    imager::pad(50L, "xy") %>%
+    {max(.) - .} %>%
+    imager::as.cimg(.) %>%
+    imager::save.image(
+      file.path(tmp_dir, paste0("comp", "_", idx, ".jpg")),
+      quality = 1.0
+    )
+}
+# export ruler
+ruler %>%
+  .[[1L]] %>%
+  imager::autocrop(.) %>%
+  imager::pad(20L, "xy") %>%
+  {max(.) - .} %>%
+  imager::as.cimg(.) %>%
+  imager::save.image(file.path(tmp_dir, "ruler.jpg"), quality = 1.0)
+
+
+# Measurements through Momocs --------------------------------------------------
+
+# import jpg file
+coo <- import_jpg(jpg.paths = file.path(tmp_dir, "comp_1.jpg"))
+# find contour
+ct_bis <- Out(coo)
+# plot contour
+ct_bis[1L] %>% Momocs::coo_plot()
+
+# TODO: some useful functions to investigate:
+#  * Momocs::coo_oscillo : shape analysis (Fourier elliptic and the likes)
+#
+#  * Momocs::coo_intersect_segment, Momocs::coo_intersect_angle : find points of
+#    a contour that intersect with a line defined by a segment (or an angle)
+#
+#  * Momocs::coo_right, Momocs::coo_left, Momocs::coo_top, Momocs::coo_down :
+#    to keep only the right (left, top or down) part of the contour
+#
+#  * Momocs::coo_untiltx : correct rotational biases appearing after applying
+#    sliding methods.
+#
+# There are more interesting functions, see the doc:
+# https://cran.r-project.org/web/packages/Momocs/Momocs.pdf
diff --git a/scripts/pixset.R b/scripts/pixset.R
new file mode 100644
index 0000000000000000000000000000000000000000..46a2e84a6f74849760b8dd140b77e4cf002cef82
--- /dev/null
+++ b/scripts/pixset.R
@@ -0,0 +1,160 @@
+####
+# Utility functions for imager::pixset objects.
+####
+
+import::here("dplyr", "inner_join", .character_only = TRUE)
+import::here(
+  "imager",
+  c(
+    "as.cimg", "as.pixset", "get.stencil", "imshift", "is.pixset", "parany",
+    "px.borders", "where"
+  ),
+  .character_only = TRUE
+)
+import::here("magrittr", "%>%", .character_only = TRUE)
+
+# local imports
+import::here("utils.R", c("ehull", "square_stencil"), .character_only = TRUE)
+
+
+#' Get centroid of a pixset.
+#'
+#' @param pixset an imager::pixset
+#'
+#' @returns an integer vector
+get_centroid <- function(pixset) {
+  centroid <- pixset %>%
+    where() %>%
+    colMeans() %>%
+    as.integer()
+  return(centroid)
+}
+
+
+#' Center a pixset.
+#'
+#' @param pixset an imager::pixset
+#'
+#' @returns an imager::pixset
+px_center <- function(pixset) {
+  centroid <- get_centroid(pixset)
+  delta <- dim(pixset)[1L:2L] / 2L - centroid
+  out <- pixset %>%
+    as.cimg() %>%
+    imshift(delta_x = delta[1L], delta_y = delta[2L]) %>%
+    as.pixset()
+  return(out)
+}
+
+
+#' Intersect borders
+#'
+#' Test whether a pixset intersect an image borders.
+#'
+#' @param pixset an imager::pixset
+#' @param img an imager::cimg
+#'
+#' @returns a vector of boolean
+intersect_borders <- function(pixset, img) {
+  # extract the image borders (as pixels coordinates)
+  borders <- img %>%
+    px.borders() %>%
+    where()
+  # count the number of pixels intersecting the borders
+  pix_on_borders <- suppressMessages(
+    pixset %>%
+      where() %>%
+      inner_join(borders) %>%
+      nrow()
+  )
+  return(pix_on_borders > 0L)
+}
+
+
+#' Combine pixset
+#'
+#' Combine pixsets into a reference pixset according to a shared neighbourhood.
+#' The neighbourhood is defined as a squared stencil centered around each
+#' candidate pixset.
+#'
+#' @param pixset_ref an imager::pixset, the reference.
+#' @param pixset_list a list of imager::pixset, the list of pixsets to combine
+#' with the reference.
+#' @param padding an integer, the stencil length
+#'
+#' @returns a vector of boolean
+combine <- function(pixset_ref, pixset_list, padding = 50L) {
+  # convert candidat to list if there is only one
+  if (!is.list(pixset_list) & is.pixset(pixset_list)) {
+    pixset_list <- list(pixset_list)
+  }
+  # iterate over parts
+  for (idx in seq_along(pixset_list)) {
+    # get centroid
+    centroid <- get_centroid(pixset_list[[idx]])
+    # define stencil for neighborhood
+    stencil <- square_stencil(centroid, padding, pixset_ref)
+    # check overlap
+    overlap <- pixset_ref %>%
+      get.stencil(stencil, x = centroid[1L], y = centroid[2L]) %>%
+      sum()
+    # merge if overlap
+    if (overlap > 0L) {
+      pixset_ref <- parany(list(pixset_ref, pixset_list[[idx]]))
+    }
+  }
+  return(pixset_ref)
+}
+
+combine_bis <- function(pixset_ref, pixset_list, padding = 25L) {
+  # convert candidat to list if there is only one
+  if (!is.list(pixset_list) & is.pixset(pixset_list)) {
+    pixset_list <- list(pixset_list)
+  }
+  # get centroid
+  centroid <- get_centroid(pixset_ref)
+  # define stencil for neighborhood
+  stencil <- square_stencil(centroid, padding, pixset_ref)
+  # iterate over parts
+  for (idx in seq_along(pixset_list)) {
+    # check overlap
+    overlap <- pixset_list[[idx]] %>%
+      get.stencil(stencil, x = centroid[1L], y = centroid[2L]) %>%
+      sum()
+    # merge if overlap
+    if (overlap > 0L) {
+      pixset_ref <- parany(list(pixset_ref, pixset_list[[idx]]))
+    }
+  }
+  return(pixset_ref)
+}
+
+
+#' Rotation angle
+#'
+#' Get the rotation angle to align the abdomen along the x-axis. The function
+#' fits an ellipsoid hull around the pixset to derive the rotation angle.
+#'
+#' @param pixset an imager::pixset
+#' @param size an integer, the morphological opening factor (optional)
+#'
+#' @returns a real
+rotation_angle <- function(pixset, size = 6L) {
+  # compute ellipsoid hull
+  eh <- ehull(pixset, size)
+  # get semi-axis lengths
+  l_term <- (eh$cov[1L, 1L] + eh$cov[2L, 2L]) / 2L
+  r_term <- sum(
+    c((eh$cov[1L, 1L] - eh$cov[2L, 2L]) / 2L, eh$cov[1L, 2L]) ** 2L
+  )
+  lambda_1 <- l_term + sqrt(r_term)
+  lambda_2 <- l_term - sqrt(r_term)
+  axis_l <- c(sqrt(lambda_1), sqrt(lambda_2))
+  # get rotation angle (from x-axis)
+  if (eh$cov[1L, 2L] != 0L) {
+    theta <- pi - atan2(lambda_1 - eh$cov[1L, 1L], eh$cov[1L, 2L])
+  } else {
+    theta <- ifelse(eh$cov[1L, 1L] >= eh$cov[2L, 2L], 0L, pi / 2L)
+  }
+  return(theta)
+}
diff --git a/scripts/utils.R b/scripts/utils.R
new file mode 100644
index 0000000000000000000000000000000000000000..6a025ba6d50c3a8e4a1aa302b95bcf9458bb89de
--- /dev/null
+++ b/scripts/utils.R
@@ -0,0 +1,120 @@
+####
+# Additional utility functions.
+####
+
+# global imports
+import::here("cluster", "ellipsoidhull", .character_only = TRUE)
+import::here(
+  "imager",
+  c(
+    "as.pixset", "contours", "is.cimg", "is.pixset", "%inr%",
+    "mopening_square", "where"
+  ),
+  .character_only = TRUE
+)
+import::here("mclust", "densityMclust", .character_only = TRUE)
+import::here("purrr", c("keep", "map"), .character_only = TRUE)
+
+
+#' Sample pixel values from histogram
+#'
+#' @param obj an array-like object (img, pixset)
+#' @param ratio a real, the sampling ratio.
+#'
+#' @returns an integer vector
+sample_histogram <- function(obj, ratio = 0.02) {
+  n_samples <- min(1e4L, prod(dim(obj)[1L:2L]))
+  n_breaks <- as.integer(n_samples * ratio)
+  hist <- hist(obj, breaks = n_breaks, plot = FALSE)
+  bins <- with(
+    hist, sample(length(mids), n_samples, p = density, replace = TRUE)
+  )
+  sample <- runif(
+    length(bins), hist$breaks[bins], hist$breaks[bins + 1L]
+  )
+  return(sample)
+}
+
+
+#' Square stencil
+#'
+#' Define a square stencil within the boundaries of an image cropping points
+#' that are out of bounds.
+#'
+#' @param centroid an integer vector, the stencil centroid.
+#' @param pad an integer, the padding around the stencil centroid.
+#' @param obj an imager::cimg or an imager::pixset
+#'
+#' @returns a data.frame
+square_stencil <- function(centroid, pad, obj) {
+
+  # get image dimensions
+  if (is.cimg(obj) | is.pixset(obj)) {
+    dims <- as.integer(dim(obj)[1L:2L])
+  } else {
+    stop("Invalid 'obj' argument.")
+  }
+  # check bounds
+  bounds <- c(
+    ifelse(centroid - pad < 1L, -centroid + 1L, -pad),
+    ifelse(pad + centroid > dims, dims - centroid, pad)
+  )
+  # define stencil
+  stencil <- expand.grid(
+    dx = seq(bounds[1L], bounds[3L]),
+    dy = seq(bounds[2L], bounds[4L])
+  )
+  return(stencil)
+}
+
+
+#' Centimeter to pixel
+#'
+#' Get the centimeter to pixel conversion factor from a ruler defined as
+#' a pixset.
+#'
+#' @param ruler an imager::pixset
+#' @param quantile a real, the quantile level used for thresholding.
+#'
+#' @returns a data.frame
+cm_to_pixel <- function(ruler, quantile = 5e-3) {
+
+  # get ruler contours size
+  ct_size <- ruler %>%
+    contours(nlevels = 1L) %>%
+    map(~ length(.$x)) %>%
+    unlist()
+  # estimate the distribution by a Gaussian
+  gm <- densityMclust(log10(ct_size), G = 1L, plot = FALSE)
+  # threshold to discriminate square contours
+  thresholds <- qnorm(
+    c(quantile, 1L - quantile),
+    gm$parameters$mean[1L],
+    sqrt(gm$parameters$variance$sigmasq[1L])
+  )
+  # get the conversion factor cm to pixel
+  conv_factor <- ct_size %>%
+    keep(~ (. %inr% 10L ** thresholds)) %>%
+    {. / 4L} %>%
+    mean()
+  return(conv_factor)
+}
+
+
+#' Ellipsoid hull
+#'
+#' Fit an ellipsoid hull for a pixset with optional morphological opening.
+#'
+#' @param pixset an imager::pixset
+#' @param size an integer, the morphological opening factor.
+#'
+#' @returns a data.frame
+ehull <- function(pixset, size = 6L) {
+  out <- pixset %>%
+    mopening_square(size) %>%
+    as.pixset() %>%
+    where() %>%
+    as.matrix() %>%
+    ellipsoidhull()
+  return(out)
+}