001// Copyright (c) FIRST and other WPILib contributors. 002// Open Source Software; you can modify and/or share it under the terms of 003// the WPILib BSD license file in the root directory of this project. 004 005package edu.wpi.first.apriltag; 006 007import com.fasterxml.jackson.annotation.JsonAutoDetect; 008import com.fasterxml.jackson.annotation.JsonCreator; 009import com.fasterxml.jackson.annotation.JsonIgnore; 010import com.fasterxml.jackson.annotation.JsonIgnoreProperties; 011import com.fasterxml.jackson.annotation.JsonProperty; 012import com.fasterxml.jackson.databind.ObjectMapper; 013import edu.wpi.first.math.geometry.Pose3d; 014import edu.wpi.first.math.geometry.Rotation3d; 015import edu.wpi.first.math.geometry.Translation3d; 016import java.io.IOException; 017import java.io.InputStream; 018import java.io.InputStreamReader; 019import java.io.UncheckedIOException; 020import java.nio.charset.StandardCharsets; 021import java.nio.file.Path; 022import java.util.ArrayList; 023import java.util.HashMap; 024import java.util.List; 025import java.util.Map; 026import java.util.Objects; 027import java.util.Optional; 028 029/** 030 * Class for representing a layout of AprilTags on a field and reading them from a JSON format. 031 * 032 * <p>The JSON format contains two top-level objects, "tags" and "field". The "tags" object is a 033 * list of all AprilTags contained within a layout. Each AprilTag serializes to a JSON object 034 * containing an ID and a Pose3d. The "field" object is a descriptor of the size of the field in 035 * meters with "width" and "length" values. This is to account for arbitrary field sizes when 036 * transforming the poses. 037 * 038 * <p>Pose3ds in the JSON are measured using the normal FRC coordinate system, NWU with the origin 039 * at the bottom-right corner of the blue alliance wall. {@link #setOrigin(OriginPosition)} can be 040 * used to change the poses returned from {@link AprilTagFieldLayout#getTagPose(int)} to be from the 041 * perspective of a specific alliance. 042 * 043 * <p>Tag poses represent the center of the tag, with a zero rotation representing a tag that is 044 * upright and facing away from the (blue) alliance wall (that is, towards the opposing alliance). 045 */ 046@JsonIgnoreProperties(ignoreUnknown = true) 047@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE) 048public class AprilTagFieldLayout { 049 /** Common origin positions for the AprilTag coordinate system. */ 050 public enum OriginPosition { 051 /** Blue alliance wall, right side. */ 052 kBlueAllianceWallRightSide, 053 /** Red alliance wall, right side. */ 054 kRedAllianceWallRightSide, 055 } 056 057 private final Map<Integer, AprilTag> m_apriltags = new HashMap<>(); 058 059 @JsonProperty(value = "field") 060 private FieldDimensions m_fieldDimensions; 061 062 private Pose3d m_origin; 063 064 /** 065 * Construct a new AprilTagFieldLayout with values imported from a JSON file. 066 * 067 * @param path Path of the JSON file to import from. 068 * @throws IOException If reading from the file fails. 069 */ 070 public AprilTagFieldLayout(String path) throws IOException { 071 this(Path.of(path)); 072 } 073 074 /** 075 * Construct a new AprilTagFieldLayout with values imported from a JSON file. 076 * 077 * @param path Path of the JSON file to import from. 078 * @throws IOException If reading from the file fails. 079 */ 080 public AprilTagFieldLayout(Path path) throws IOException { 081 AprilTagFieldLayout layout = 082 new ObjectMapper().readValue(path.toFile(), AprilTagFieldLayout.class); 083 m_apriltags.putAll(layout.m_apriltags); 084 m_fieldDimensions = layout.m_fieldDimensions; 085 setOrigin(OriginPosition.kBlueAllianceWallRightSide); 086 } 087 088 /** 089 * Construct a new AprilTagFieldLayout from a list of {@link AprilTag} objects. 090 * 091 * @param apriltags List of {@link AprilTag}. 092 * @param fieldLength Length of the field the layout is representing in meters. 093 * @param fieldWidth Width of the field the layout is representing in meters. 094 */ 095 public AprilTagFieldLayout(List<AprilTag> apriltags, double fieldLength, double fieldWidth) { 096 this(apriltags, new FieldDimensions(fieldLength, fieldWidth)); 097 } 098 099 @JsonCreator 100 private AprilTagFieldLayout( 101 @JsonProperty(required = true, value = "tags") List<AprilTag> apriltags, 102 @JsonProperty(required = true, value = "field") FieldDimensions fieldDimensions) { 103 // To ensure the underlying semantics don't change with what kind of list is passed in 104 for (AprilTag tag : apriltags) { 105 m_apriltags.put(tag.ID, tag); 106 } 107 m_fieldDimensions = fieldDimensions; 108 setOrigin(OriginPosition.kBlueAllianceWallRightSide); 109 } 110 111 /** 112 * Returns a List of the {@link AprilTag AprilTags} used in this layout. 113 * 114 * @return The {@link AprilTag AprilTags} used in this layout. 115 */ 116 @JsonProperty("tags") 117 public List<AprilTag> getTags() { 118 return new ArrayList<>(m_apriltags.values()); 119 } 120 121 /** 122 * Returns the length of the field the layout is representing in meters. 123 * 124 * @return length, in meters 125 */ 126 @JsonIgnore 127 public double getFieldLength() { 128 return m_fieldDimensions.fieldLength; 129 } 130 131 /** 132 * Returns the length of the field the layout is representing in meters. 133 * 134 * @return width, in meters 135 */ 136 @JsonIgnore 137 public double getFieldWidth() { 138 return m_fieldDimensions.fieldWidth; 139 } 140 141 /** 142 * Sets the origin based on a predefined enumeration of coordinate frame origins. The origins are 143 * calculated from the field dimensions. 144 * 145 * <p>This transforms the Pose3d objects returned by {@link #getTagPose(int)} to return the 146 * correct pose relative to a predefined coordinate frame. 147 * 148 * @param origin The predefined origin 149 */ 150 @JsonIgnore 151 public final void setOrigin(OriginPosition origin) { 152 var pose = 153 switch (origin) { 154 case kBlueAllianceWallRightSide -> Pose3d.kZero; 155 case kRedAllianceWallRightSide -> new Pose3d( 156 new Translation3d(m_fieldDimensions.fieldLength, m_fieldDimensions.fieldWidth, 0), 157 new Rotation3d(0, 0, Math.PI)); 158 }; 159 setOrigin(pose); 160 } 161 162 /** 163 * Sets the origin for tag pose transformation. 164 * 165 * <p>This transforms the Pose3d objects returned by {@link #getTagPose(int)} to return the 166 * correct pose relative to the provided origin. 167 * 168 * @param origin The new origin for tag transformations 169 */ 170 @JsonIgnore 171 public final void setOrigin(Pose3d origin) { 172 m_origin = origin; 173 } 174 175 /** 176 * Returns the origin used for tag pose transformation. 177 * 178 * @return the origin 179 */ 180 @JsonIgnore 181 public Pose3d getOrigin() { 182 return m_origin; 183 } 184 185 /** 186 * Gets an AprilTag pose by its ID. 187 * 188 * @param ID The ID of the tag. 189 * @return The pose corresponding to the ID passed in or an empty optional if a tag with that ID 190 * was not found. 191 */ 192 @SuppressWarnings("ParameterName") 193 public Optional<Pose3d> getTagPose(int ID) { 194 AprilTag tag = m_apriltags.get(ID); 195 if (tag == null) { 196 return Optional.empty(); 197 } 198 return Optional.of(tag.pose.relativeTo(m_origin)); 199 } 200 201 /** 202 * Serializes a AprilTagFieldLayout to a JSON file. 203 * 204 * @param path The path to write to. 205 * @throws IOException If writing to the file fails. 206 */ 207 public void serialize(String path) throws IOException { 208 serialize(Path.of(path)); 209 } 210 211 /** 212 * Serializes a AprilTagFieldLayout to a JSON file. 213 * 214 * @param path The path to write to. 215 * @throws IOException If writing to the file fails. 216 */ 217 public void serialize(Path path) throws IOException { 218 new ObjectMapper().writeValue(path.toFile(), this); 219 } 220 221 /** 222 * Get an official {@link AprilTagFieldLayout}. 223 * 224 * @param field The loadable AprilTag field layout. 225 * @return AprilTagFieldLayout of the field. 226 * @throws UncheckedIOException If the layout does not exist. 227 */ 228 public static AprilTagFieldLayout loadField(AprilTagFields field) { 229 if (field.m_fieldLayout == null) { 230 try { 231 field.m_fieldLayout = loadFromResource(field.m_resourceFile); 232 } catch (IOException e) { 233 throw new UncheckedIOException( 234 "Could not load AprilTagFieldLayout from " + field.m_resourceFile, e); 235 } 236 } 237 // Copy layout because the layout's origin is mutable 238 return new AprilTagFieldLayout( 239 field.m_fieldLayout.getTags(), 240 field.m_fieldLayout.getFieldLength(), 241 field.m_fieldLayout.getFieldWidth()); 242 } 243 244 /** 245 * Deserializes a field layout from a resource within a internal jar file. 246 * 247 * <p>Users should use {@link AprilTagFields#loadAprilTagLayoutField()} to load official layouts 248 * and {@link #AprilTagFieldLayout(String)} for custom layouts. 249 * 250 * @param resourcePath The absolute path of the resource 251 * @return The deserialized layout 252 * @throws IOException If the resource could not be loaded 253 */ 254 public static AprilTagFieldLayout loadFromResource(String resourcePath) throws IOException { 255 InputStream stream = AprilTagFieldLayout.class.getResourceAsStream(resourcePath); 256 if (stream == null) { 257 // Class.getResourceAsStream() returns null if the resource does not exist. 258 throw new IOException("Could not locate resource: " + resourcePath); 259 } 260 InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8); 261 try { 262 return new ObjectMapper().readerFor(AprilTagFieldLayout.class).readValue(reader); 263 } catch (IOException e) { 264 throw new IOException("Failed to load AprilTagFieldLayout: " + resourcePath); 265 } 266 } 267 268 @Override 269 public boolean equals(Object obj) { 270 return obj instanceof AprilTagFieldLayout layout 271 && m_apriltags.equals(layout.m_apriltags) 272 && m_origin.equals(layout.m_origin); 273 } 274 275 @Override 276 public int hashCode() { 277 return Objects.hash(m_apriltags, m_origin); 278 } 279 280 @JsonIgnoreProperties(ignoreUnknown = true) 281 @JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.NONE) 282 private static class FieldDimensions { 283 @SuppressWarnings("MemberName") 284 @JsonProperty(value = "length") 285 public final double fieldLength; 286 287 @SuppressWarnings("MemberName") 288 @JsonProperty(value = "width") 289 public final double fieldWidth; 290 291 @JsonCreator() 292 FieldDimensions( 293 @JsonProperty(required = true, value = "length") double fieldLength, 294 @JsonProperty(required = true, value = "width") double fieldWidth) { 295 this.fieldLength = fieldLength; 296 this.fieldWidth = fieldWidth; 297 } 298 } 299}