Reading and Writing SDFormat XML

import matplotlib.image as mpimg
import matplotlib.pyplot as plt

# create the thumbnail image for this example
header = mpimg.imread("sdformat-read-write-thumb.png")
fig, ax = plt.subplots(figsize=(3, 3), dpi=80)
ax.imshow(header)
ax.axis("off")
fig.show()
plot sdformat read write

Scikit-bot comes with bindings and schemata to read and write SDFormat. SDFormat (Simulation Description Format) is a - pretty extensive - XML format that is primarily intended to describe simulation worlds and robots. It was originially developed for the Gazebo simulator (now superseeded by Ignition Gazebo) and is developed by Ignition robotics. If you are unfamiliar with SDFormat, take a look at their official website and check the specification and documentation.

Note

While this example is primarily concerned with reading/writing SDF in python, it is worthwhile to point out that scikit-bot ships with XSD1.1 bindings for SDFormat. You can find them in your module folder or here on GitHub.

Importing the Bindings

from dataclasses import dataclass, field
from pathlib import Path
from typing import Tuple
import traceback

import skbot.ignition as ign
from skbot.ignition.sdformat.bindings import v18

The SDFormat bindings are part of scikit-bot’s ignition module. The ignition module has additional dependencies, and - as such - you will have to install these as well. Instructions on how to do this can be found in the module's documentation.

Another thing to note is that the SDFormat bindings are not imported together with the ignition module. This is a conscious choice, as the bindings for each versions are quite large. To keep import times low, the version-specific bindings are lazy-loaded whenever scikit-bot needs them or when you import them explicitly:

from skbot.ignition.sdformat.bindings import vXX

where vXX is the version you wish to use. In this example we use v18 for SDFormat v1.8.

Reading / Deserialization

Note

You don’t have to explicitly import bindings to load SDF. The required version will be lazy-loaded for you.

Scikit-bot reads SDFormat from strings containing valid XML. While we might support directly reading from file in the future, pythons built-in ability to read files and manage the filesystem are very extensive and there is little you can’t accomplish in one or two lines. You can find examples how to do this further down on this page.

Let’s start with a small SDF string.

sdf_string = """<?xml version="1.0" ?>
<sdf version="1.8">
  <model name="camera_model">
    <link name="camera_link">
        <pose>1 2 3 0 0 0</pose>
        <sensor name="camera_sensor" type="camera">
            <camera name="awesome_camera">
                <image></image>
            </camera>
        </sensor>
    </link>
  </model>
</sdf>
"""

If you use the above SDF in Ignition Gazebo, it will place a basic perspective camera into your world at position (1, 2, 3) relative to the world’s reference frame. There is, of course, much more to SDF and you can express entire robot kinematics, animated models (actors), worlds for accurate phyics simulation, and much more. However, a tutorial on these is out of scope here, so please refer to the official docs for more information on that. Here, the above will serve as a nice example.

To read / deserialize the above SDF simply feed it into scikit-bot’s SDF reader

sdf_root: v18.Sdf = ign.sdformat.loads(sdf_string)

A few things have happened here. Scikit-bot took the string, looked at the version of the SDF inside it, and loaded the required data-bindings. It then used those bindings to parse the string and create a corresponding tree of python objects. It also populated all the missing/omitted fields with their default values where applicable. Finally, we used python type-hints to declare that sdf_root is a SDFormat v1.8 root tag. This gives us access to auto-completion within most modern IDEs and allows static type validation via mypy.

img_dims = (
    sdf_root.model.link[0].sensor[0].camera.image.height,
    sdf_root.model.link[0].sensor[0].camera.image.width,
)
print(f"The camera resolution is: {img_dims}")

Out:

The camera resolution is: (240, 320)

Auto-populated defaults is one of the advantages of using data-bindings compared to parsing with generic XML parsers like xml.etree or lxml. Another advantage is implicit validation; if your SDF contains invalid elements, you will get an exception telling you what’s off.

invalid_sdf_string = """<?xml version="1.0" ?>
<sdf version="1.8">
  <model name="camera_model">
    <invalid_tag />
  </model>
</sdf>
"""

try:
    ign.sdformat.loads(invalid_sdf_string)
except ign.sdformat.sdformat.ParseError:
    traceback.print_exc()

Out:

Traceback (most recent call last):
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/skbot/ignition/sdformat/sdformat.py", line 165, in loads
    root_el = sdf_parser.from_string(sdf, bindings.Sdf)
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/xsdata/formats/bindings.py", line 25, in from_string
    return self.from_bytes(source.encode(), clazz)
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/xsdata/formats/bindings.py", line 29, in from_bytes
    return self.parse(io.BytesIO(source), clazz)
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/xsdata/formats/dataclass/parsers/bases.py", line 53, in parse
    result = handler.parse(source)
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/xsdata/formats/dataclass/parsers/handlers/lxml.py", line 47, in parse
    return self.process_context(ctx)
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/xsdata/formats/dataclass/parsers/handlers/lxml.py", line 59, in process_context
    element.nsmap,
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/xsdata/formats/dataclass/parsers/bases.py", line 87, in start
    child = item.child(qname, attrs, ns_map, len(objects))
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/xsdata/formats/dataclass/parsers/nodes/element.py", line 339, in child
    raise ParserError(f"Unknown property {self.meta.qname}:{qname}")
xsdata.exceptions.ParserError: Unknown property model:invalid_tag

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/checkouts/latest/examples/plot_sdformat-read-write.py", line 144, in <module>
    ign.sdformat.loads(invalid_sdf_string)
  File "/home/docs/checkouts/readthedocs.org/user_builds/robotics-python/envs/latest/lib/python3.7/site-packages/skbot/ignition/sdformat/sdformat.py", line 167, in loads
    raise ParseError("Invalid SDFormat XML.") from e
skbot.ignition.sdformat.exceptions.ParseError: Invalid SDFormat XML.

sdformat.loads() comes with a few keyword arguments that are noteworthy. Sometimes you may wish to force loading SDF as a certain version that differs from the one reported in the file. You can do this by specifying the version to use.

v17_string = ign.sdformat.loads(sdf_string, version="1.7")
print(f"Forced version loading: {type(v17_string)}")
print(f"Default version loading: {type(sdf_root)}")

Out:

Forced version loading: <class 'skbot.ignition.sdformat.bindings.v17.sdf.Sdf'>
Default version loading: <class 'skbot.ignition.sdformat.bindings.v18.sdf.Sdf'>

As you can see, the object tree was built out of objects from the v1.7 bindings; which - as hinted above - were imported on-demand.

Sometimes, you may wish to check the version of a SDF string programatically without loading any bindings. In such cases you can use the function below. It will be faster for large SDF compared to fully parsing it, since it doesn’t parse the entire string; only until the opening <sdf> element.

ver = ign.sdformat.get_version(sdf_string)

Further, you can replace classes in the tree with subclasses to add customization, e.g., converting data-types into more python friendly fromats

@dataclass
class MyImage(v18.Sensor.Camera.Image):
    im_shape: Tuple[int, int] = field(init=False)

    def __post_init__(self):
        self.im_shape = (
            sdf_root.model.link[0].sensor[0].camera.image.height,
            sdf_root.model.link[0].sensor[0].camera.image.width,
        )


v18_string: v18.Sdf = ign.sdformat.loads(
    sdf_string, custom_constructor={v18.Sensor.Camera.Image: MyImage}
)

im_shape = v18_string.model.link[0].sensor[0].camera.image.im_shape
print(f"The camera resolution is: {im_shape}")

Out:

The camera resolution is: (240, 320)

How do you know which class to overwrite? Scikit-bot’s binding layout follows the layout of the spec. On the spec’s website you can find several tabs, each representing a SDF element. Each tab then lists a tree of sub-elements and where they can appear. The data-bindings have the same layout, with exception of Model, which is called ModelModel and State.Model which is called StateModel. This is done to avoid a name collision in both the bindings and the XSD schemata. Alternatively, you can look up each element in the binding’s API documentation.

Writing / Serialization

Of course scikit-bot can also turn SDF object trees back into SDF strings. Just like before, scikit-bot converts the tree into a string, which you can then write to file, should you wish to do so.

result_string = ign.sdformat.dumps(sdf_root)
print(result_string)

Out:

<?xml version="1.0" encoding="UTF-8"?>
<sdf version="1.8"><model name="camera_model"><static>false</static><self_collide>false</self_collide><allow_auto_disable>true</allow_auto_disable><enable_wind>false</enable_wind><link name="camera_link"><gravity>true</gravity><enable_wind>false</enable_wind><self_collide>false</self_collide><kinematic>false</kinematic><must_be_base_link>false</must_be_base_link><pose>1 2 3 0 0 0</pose><sensor name="camera_sensor" type="camera"><always_on>false</always_on><update_rate>0.0</update_rate><visualize>false</visualize><topic>__default__</topic><enable_metrics>false</enable_metrics><camera name="awesome_camera"><horizontal_fov>1.047</horizontal_fov><image><width>320</width><height>240</height><format>R8G8B8</format></image><visibility_mask>4294967295</visibility_mask></camera></sensor></link></model></sdf>

Note that the resulting SDF string differs from the original SDF string in two ways: (1) default values have been set explicitly, and (2) all unneeded white-space is removed. To get a neatly-formatted, human-readable string you have to set the format=True flag.

serialized_sdf = ign.sdformat.dumps(sdf_root, format=True)
print(serialized_sdf)

Out:

<?xml version="1.0" encoding="UTF-8"?>
<sdf version="1.8">
  <model name="camera_model">
    <static>false</static>
    <self_collide>false</self_collide>
    <allow_auto_disable>true</allow_auto_disable>
    <enable_wind>false</enable_wind>
    <link name="camera_link">
      <gravity>true</gravity>
      <enable_wind>false</enable_wind>
      <self_collide>false</self_collide>
      <kinematic>false</kinematic>
      <must_be_base_link>false</must_be_base_link>
      <pose>1 2 3 0 0 0</pose>
      <sensor name="camera_sensor" type="camera">
        <always_on>false</always_on>
        <update_rate>0.0</update_rate>
        <visualize>false</visualize>
        <topic>__default__</topic>
        <enable_metrics>false</enable_metrics>
        <camera name="awesome_camera">
          <horizontal_fov>1.047</horizontal_fov>
          <image>
            <width>320</width>
            <height>240</height>
            <format>R8G8B8</format>
          </image>
          <visibility_mask>4294967295</visibility_mask>
        </camera>
      </sensor>
    </link>
  </model>
</sdf>

Fuel Support

One of the more advanced - but very awesome - features of SDF is that you can include models from other sources; in particular the fuel server. This allows you to compose a simulation world from several files and re-use them many times.

By default, the parser provided by scikit-bot does not auto-include models. This is a conscious choice, as we want to remain unoppinionated as to what you wish to do with the include element. You may indeed wish to download the model; however, you may alternatively whish to validate the SDF file and need the actual include element. Another aspect is that the included model may be specified in a different SDFormat version than the one that is currently being parsed. Hence, simply copying it into the current object-tree may be a bad idea (SDF isn’t always comptible between versions). As such, the bindings don’t resolve include elements.

That said, you can very easily download and parse nested fuel models using scikit-bot:

sdf_string = """<?xml version="1.0" ?>
<sdf version="1.8">
<model name="parent_model">
    <include>
    <uri>https://fuel.ignitionrobotics.org/1.0/Gambit/models/Pitcher Base</uri>
    <name>Awesome Pitcher</name>
    <pose>0 0 0 0 -0 1.5708</pose>
    </include>
</model>
</sdf>
"""

sdf_root: v18.Sdf = ign.sdformat.loads(sdf_string)

nested_models = list()
for include_el in sdf_root.model.include:
    nested_sdf = ign.get_fuel_model(include_el.uri)
    nested_models.append(ign.sdformat.loads(nested_sdf))

nested_sdf_string = ign.sdformat.dumps(nested_models[0], format=True)

print(f"The included model:\n{nested_sdf_string}")

Building SDF from Scratch

Finally, we can use the data-bindings explicitly to create an object tree of SDF elements that can then be converted into a SDF string.

We can choose a declarative style and nest constructors

ground_plane = v18.model.Model(
    static=True,
    name="ground_plane",
    link=[
        v18.Link(
            name="ground_plane_link",
            collision=v18.Collision(
                geometry=v18.Geometry(
                    plane=v18.Geometry.Plane(normal="1 0 0", size="1.4 6.3")
                )
            ),
            visual=v18.Visual(
                geometry=v18.Geometry(
                    plane=v18.Geometry.Plane(normal="0 1 0", size="2 4")
                )
            ),
        )
    ],
)

Or we can use a procedual style that sequentially constructs the objects and assigns them.

# create an empty model
box = v18.model.Model()
box.pose = v18.model.Model.Pose("0 0 2.5 0 0 0", relative_to="ground_plane")
box.link.append(v18.Link())  # link is a list; a model can have many links
box.link[0].name = "link"

# set-up collision
box.link[0].collision.append(v18.Collision())
box.link[0].collision[0].name = "collision"
box.link[0].collision[0].geometry = v18.Geometry()
box.link[0].collision[0].geometry.box = v18.Geometry.Box("1 2 3")
box.link[0].collision[0].surface = v18.Collision.Surface()
box.link[0].collision[0].surface.contact = v18.Collision.Surface.Contact(
    collide_bitmask=171
)

# set-up visual
box.link[0].visual.append(v18.Visual())
box.link[0].visual[0].name = "box_vis"
box.link[0].visual[0].geometry = v18.Geometry()
box.link[0].visual[0].geometry.box = v18.Geometry.Box("1 2 3")

and once done we can serialize the object tree to string. We can then use python to write the resulting string to disk

root = v18.Sdf(
    version="1.8", world=[v18.World(name="shapes_world", model=[ground_plane, box])]
)
sdf_string = ign.sdformat.dumps(root, format=True)
print(sdf_string)

Path("my_world.sdf").write_text(sdf_string)

# or (more old-school) via open(...)
with open("my_world.sdf", "w") as sdf_file:
    print(sdf_string, file=sdf_file)

Out:

<?xml version="1.0" encoding="UTF-8"?>
<sdf version="1.8">
  <world name="shapes_world">
    <gravity>0 0 -9.8</gravity>
    <magnetic_field>5.5645e-6 22.8758e-6 -42.3884e-6</magnetic_field>
    <model name="ground_plane">
      <static>true</static>
      <self_collide>false</self_collide>
      <allow_auto_disable>true</allow_auto_disable>
      <enable_wind>false</enable_wind>
      <link name="ground_plane_link">
        <gravity>true</gravity>
        <enable_wind>false</enable_wind>
        <self_collide>false</self_collide>
        <kinematic>false</kinematic>
        <must_be_base_link>false</must_be_base_link>
        <collision>
          <laser_retro>0.0</laser_retro>
          <max_contacts>10</max_contacts>
          <geometry>
            <plane>
              <normal>1 0 0</normal>
              <size>1.4 6.3</size>
            </plane>
          </geometry>
        </collision>
        <visual>
          <cast_shadows>true</cast_shadows>
          <laser_retro>0.0</laser_retro>
          <transparency>0.0</transparency>
          <visibility_flags>4294967295</visibility_flags>
          <geometry>
            <plane>
              <normal>0 1 0</normal>
              <size>2 4</size>
            </plane>
          </geometry>
        </visual>
      </link>
    </model>
    <model>
      <static>false</static>
      <self_collide>false</self_collide>
      <allow_auto_disable>true</allow_auto_disable>
      <enable_wind>false</enable_wind>
      <pose relative_to="ground_plane">0 0 2.5 0 0 0</pose>
      <link name="link">
        <gravity>true</gravity>
        <enable_wind>false</enable_wind>
        <self_collide>false</self_collide>
        <kinematic>false</kinematic>
        <must_be_base_link>false</must_be_base_link>
        <collision name="collision">
          <laser_retro>0.0</laser_retro>
          <max_contacts>10</max_contacts>
          <geometry>
            <box>
              <size>1 2 3</size>
            </box>
          </geometry>
          <surface>
            <contact>
              <collide_without_contact>false</collide_without_contact>
              <collide_without_contact_bitmask>1</collide_without_contact_bitmask>
              <collide_bitmask>171</collide_bitmask>
              <category_bitmask>65535</category_bitmask>
              <poissons_ratio>0.3</poissons_ratio>
              <elastic_modulus>-1.0</elastic_modulus>
            </contact>
          </surface>
        </collision>
        <visual name="box_vis">
          <cast_shadows>true</cast_shadows>
          <laser_retro>0.0</laser_retro>
          <transparency>0.0</transparency>
          <visibility_flags>4294967295</visibility_flags>
          <geometry>
            <box>
              <size>1 2 3</size>
            </box>
          </geometry>
        </visual>
      </link>
    </model>
  </world>
</sdf>

to show the counterpart of the above, this is how you would read SDF from disk

sdf_string = Path("my_world.sdf").read_text()
root = ign.sdformat.loads(sdf_string)

# or again via open(...)
with open("my_world.sdf", "r") as sdf_file:
    sdf_string = sdf_file.read()
root = ign.sdformat.loads(sdf_string)

A interesting use-case for manually specifying SDF is that we can let objects share child elements and once we update the properties of the child they will update in all the places where the child is used inside the SDF. Note, however, that elements will (of course) not share a child anymore once they have been serialized.

box_geometry = v18.Geometry()
box_geometry.box = v18.Geometry.Box("1 2 3")
box.link[0].collision[0].geometry = box_geometry
box.link[0].visual[0].geometry = box_geometry

print(ign.sdformat.dumps(box, format=True))

Out:

<?xml version="1.0" encoding="UTF-8"?>
<model>
  <static>false</static>
  <self_collide>false</self_collide>
  <allow_auto_disable>true</allow_auto_disable>
  <enable_wind>false</enable_wind>
  <pose relative_to="ground_plane">0 0 2.5 0 0 0</pose>
  <link name="link">
    <gravity>true</gravity>
    <enable_wind>false</enable_wind>
    <self_collide>false</self_collide>
    <kinematic>false</kinematic>
    <must_be_base_link>false</must_be_base_link>
    <collision name="collision">
      <laser_retro>0.0</laser_retro>
      <max_contacts>10</max_contacts>
      <geometry>
        <box>
          <size>1 2 3</size>
        </box>
      </geometry>
      <surface>
        <contact>
          <collide_without_contact>false</collide_without_contact>
          <collide_without_contact_bitmask>1</collide_without_contact_bitmask>
          <collide_bitmask>171</collide_bitmask>
          <category_bitmask>65535</category_bitmask>
          <poissons_ratio>0.3</poissons_ratio>
          <elastic_modulus>-1.0</elastic_modulus>
        </contact>
      </surface>
    </collision>
    <visual name="box_vis">
      <cast_shadows>true</cast_shadows>
      <laser_retro>0.0</laser_retro>
      <transparency>0.0</transparency>
      <visibility_flags>4294967295</visibility_flags>
      <geometry>
        <box>
          <size>1 2 3</size>
        </box>
      </geometry>
    </visual>
  </link>
</model>
box_geometry.box.size = "2 5 2"
print(ign.sdformat.dumps(box, format=True))

Out:

<?xml version="1.0" encoding="UTF-8"?>
<model>
  <static>false</static>
  <self_collide>false</self_collide>
  <allow_auto_disable>true</allow_auto_disable>
  <enable_wind>false</enable_wind>
  <pose relative_to="ground_plane">0 0 2.5 0 0 0</pose>
  <link name="link">
    <gravity>true</gravity>
    <enable_wind>false</enable_wind>
    <self_collide>false</self_collide>
    <kinematic>false</kinematic>
    <must_be_base_link>false</must_be_base_link>
    <collision name="collision">
      <laser_retro>0.0</laser_retro>
      <max_contacts>10</max_contacts>
      <geometry>
        <box>
          <size>2 5 2</size>
        </box>
      </geometry>
      <surface>
        <contact>
          <collide_without_contact>false</collide_without_contact>
          <collide_without_contact_bitmask>1</collide_without_contact_bitmask>
          <collide_bitmask>171</collide_bitmask>
          <category_bitmask>65535</category_bitmask>
          <poissons_ratio>0.3</poissons_ratio>
          <elastic_modulus>-1.0</elastic_modulus>
        </contact>
      </surface>
    </collision>
    <visual name="box_vis">
      <cast_shadows>true</cast_shadows>
      <laser_retro>0.0</laser_retro>
      <transparency>0.0</transparency>
      <visibility_flags>4294967295</visibility_flags>
      <geometry>
        <box>
          <size>2 5 2</size>
        </box>
      </geometry>
    </visual>
  </link>
</model>

Total running time of the script: ( 0 minutes 0.110 seconds)

Gallery generated by Sphinx-Gallery