autorope / donkeycar

Open source hardware and software platform to build a small scale self driving car.
http://www.donkeycar.com
MIT License
3.11k stars 1.28k forks source link

Donkey Car car app template is monolithic and does not support different car configurations easily #1001

Open DocGarbanzo opened 2 years ago

DocGarbanzo commented 2 years ago

Replace the car config with a modular user-friendly design

Issue description

Currently we ship a couple of templates with Donkey Car but for the majority of applications the complete.py is used. It contains all possible cameras, drive trains with motor controllers, input controllers, etc, etc. In reality though a user has a car with only one camera type, one drive train, and will likely use only one controller to drive it. For the donkey gym environment we also don't need to support all the different hardware parts as the code runs on the host computer. We have a separate simulator.py template, but I believe this is not used. And there seems to be quite an overlap in duplicated code.

Proposal

Approach

Car template

Yaml is easy to read and manipulate. Here is a car template for a simple car that uses a mock camera, the web controller and writes data to the tub.

parts:
  - mockcamera:
      outputs: [cam/image_array]
      threaded: true
  - localwebcontroller:
      inputs: [cam/image_array, tub/num_records]
      outputs: [user/angle, user/throttle, user/mode, recording]
      threaded: true
  - webfpv:
      inputs: [cam/image_array]
      threaded: true
  - tubwriter:
      arguments:
        inputs: [cam/image_array, user/angle, user/throttle, user/mode]
        types: [image_array, float, float, str]
      inputs: [cam/image_array, user/angle, user/throttle, user/mode]
      outputs: [tub/current_index]
      run_condition: recording

The car builder

The car builder reads the yaml file and creates the car. In addition it also creates a graphical representation of the car as a dot file and renders it. Here is what the above car looks like:

vehicle py

Here, the variables that are flowing from outputs to inputs are given through solid lines, and variable that connect from outputs to run conditions are represented by dotted lines. Variables that are output, but don't go anywhere are arriving in the 'Unused' part. Variables that are inputs only are currently missing in the graph. They would create a run time error, I'll add them to the graph to prevent building ill-defined car apps.

Features

In general we prefer to have a simple template with few if/else options, but sometimes you might want to switch on a part or not so we support a field add_only_if. In order to connect the template with the configuration parameters we support the syntax cfg.ABC in order to access these parameters in the configuration. Lets make the WebFpv from above configurable:

parts:
  ...
  - webfpv:
      add_only_if: cfg.USE_FPV
      inputs: [cam/image_array]
      threaded: true
  ...

Debugging the data flow in the car is easy, let's take the template above and put a typo into the output variable of the web controller: user/throttle -> usr/throttle and rebuild the car:

parts:
  ...
  - localwebcontroller:
      inputs: [cam/image_array, tub/num_records]
      outputs: [user/angle, usr/throttle, user/mode, recording]
      threaded: true
  ...  

This is the corresponding visualisation: vehicle py We see, the output usr/throttle goes into the Unused part. So even before running the car and observing that the recodings will have no throttle values we can see that the template will not work as expected.

The code

The code requires the donkey parts to be derived from a common interface that register the constructor (by default) or a create class function upon class compilation. The create() function reads the config and allow for overwrites, but for simple parts it is not required, because the yaml template allow to pass constructor parameters optionally. In the above example the WebFpv was added through a create method, but we could alternative add it like:

  - webfpv:
      arguments:
        port: cfg.WEB_CONTROL_PORT
        mode: cfg.WEB_INIT_MODE
      inputs: [cam/image_array]
      threaded: true

It leads to a cleaner design though to hide the initialisation parameters from the user if these are stored as config parameters. But because the yaml file is a configuration file itself, the users can edit and store for their cars, we could set any such parameters in car config directly, like:

  - webfpv:
      arguments:
        port: 8890
        mode: 'user'
      inputs: [cam/image_array]
      threaded: true

This would make a lot of configuration parameters obsolete.

Supporting own user parts

We add a dynamic loading of a myparts.py file in the mycar directory. This will allow users to write an own Donkey Car part w/o messing with any of the project code, which usually requires having your own GH account, forking the project, etc, etc. I will add a simple example of the required code to drive a single LED from the PCA9685 pwm driver board, which for example, would switch on, once you are in recording mode. The following steps would be required to get this part added: 1) Adding the new part to the yaml file, like:

  - led:
      inputs: [recording]

And in the myparts.py:

class LED(Createable):
  def __init(output_pin=''):
    super().__init(output_pin=output_pin)
    # some more code here

  def run(recording):
     # some code here

  @classmethod
  def create(cls, cfg, kwargs):
    # some code here

Because the new part is derived from Createable (the create() method is optional) and the yaml entry is well defined, the part would be recognised by the vehicle and it will print in the graphical representation.

Ezward commented 2 years ago

I really like this direction. I really like that the user could spin-up a vehicle without needing any Python or even a configuration file. There a few things to think about.

The yaml approach, without some other support, will require the user to read a lot of python to figure out which parts are available and what are their constructor arguments, inputs and outputs. The user, who we want to insulate from the needing to know the internals of the Python code, must read each part's python code to know what are the constructor arguments, the run() methods arguments (the inputs) and the run method's return values (the outputs). So in this model we are actually requiring the user to read the python and pull out this information before they can create their yaml file. In some ways that is worse than the current situation, which for the most part only requires uncommenting and editing the myconfig.py file. However, with more documentation and/or tooling we can overcome that issue.

The myconfig.py file is currently a de-facto yellow pages for parts and their configuration. If a part doesn't have an entry in myconfig.py it may never get used. If we move this new approach then we won't rely on a monolithic configuration file (and that is good), but how does a user know what parts are available and how to configure them?

How do we handle more than one instance of a part?

I don't want any of the questions I have raised to diminish how important this proposal is. Donkeycar is targeted at relatively sophisticated beginners and even they often get stuck. This approach could open up autonomous racing/robotics to a lot more people and I would think with much less pain.

cfox570 commented 2 years ago

The current monolithic approach is geared towards beginners and those who are not python coders. The approach should initially be geared towards those who are able to take a deeper dive into the code to build there own parts.

I have prototyped a "template" that uses this method.

I am using the code below today and have found it great to debugand change part configuration. Seeing the inputs and outputs all in one place is great as I change the parts in use.

Here is the YAML:

parts:
    donkeycar.parts.controller:
        enable: True
        class: LocalWebController
        args:
            port: cfg.WEB_CONTROL_PORT
            mode: cfg.WEB_INIT_MODE
        inputs:  [cam/image_array, tub/num_records] 
        outputs: [user/angle, user/throttle, user/mode, recording]
        thread: True

    donkeycar.parts.fx.helpers:
        enable: True
        class: PilotCondition
        args: {}
        inputs:  [user/mode] 
        outputs: [run_pilot]

    donkeycar.parts.fx.helpers:
        enable: True
        class: AI_Pilot
        args:
            cfg: cfg
        inputs:  [cam/image_array] 
        outputs: [pilot/angle, pilot/throttle]
        run_condition: run_pilot

    donkeycar.parts.launch: 
        enable: True
        class: AiLaunch
        args: 
            launch_duration: cfg.AI_LAUNCH_DURATION
            launch_throttle: cfg.AI_LAUNCH_THROTTLE
            keep_enabled:    cfg.AI_LAUNCH_KEEP_ENABLED
        inputs: [user/mode, pilot/throttle]
        outputs: [pilot/throttle]

    donkeycar.parts.fx.helpers: 
        enable: True
        class: DriveMode
        args:
            cfg: cfg
        inputs:  [user/mode, user/angle, user/throttle, pilot/angle, pilot/throttle]
        outputs: [angle, throttle]

    donkeycar.parts.dgym:
        enable: True
        class: DonkeyGymEnv
        args:
            sim_path: cfg.DONKEY_SIM_PATH
            host:     cfg.SIM_HOST
            env_name: cfg.DONKEY_GYM_ENV_NAME
            conf:     cfg.GYM_CONF
            delay:    cfg.SIM_ARTIFICIAL_LATENCY
        inputs:  [angle, throttle, brake]
        outputs: [cam/image_array]
        thread: True

    donkeycar.parts.tub_v2:
        enable: True
        class: TubWriter
        args:
            base_path: cfg.DATA_PATH
            inputs:   [cam/image_array, user/angle, user/throttle, user/mode]
            types:    [image_array, float, float, str]
            metadata: []
        inputs:  [cam/image_array,user/angle, user/throttle, user/mode]
        outputs: [tub/num_records]
        run_condition: recording

Here is the replacement for manage.py:



import logging
import import lib
import yaml
from docent import docent
import donkeycar as dk
#__________________________________ ASSEMBLE THE VEHICLE________________________

def assemble(cfg):
    #Initialize car
    V = dk.vehicle.Vehicle()

    # Load the YAML file specifying the parts
    with open(cfg.PARTS_PATH) as file:
        try:
            dict = yaml.safe_load(file)
        except yaml.YAMLError as exception:
            logger.error(exception)

    parts = dict.get("parts")
    if parts is None:
        logger.error("parts.yml is missing the parts key")
        raise Exception()

    for mod_name, class_name in parts.items():
        #import module <mod_name>
        module = importlib.import_module(mod_name)
        logger.info(f'{module.__name__} imported.')

        #create part object from <class_name>
        part_class = getattr(module, class_name)
        part = part_class(cfg)
        logger.info(f'    {class_name} part created')

        #add part to vehicle
        V.add(part, inputs=part.inputs, outputs=part.outputs, threaded=part.threaded, run_condition=part.run_condition)
        logger.info(f'    {class_name} part added to vehicle')
        logger.info(f'    Inputs  {part.inputs}')
        logger.info(f'    Outputs {part.outputs}')

    return V

#__________________________________ MAIN _________________________________________
if __name__ == '__main__':
    # load and check command line options
    args = docopt(__doc__)

    # assemble vehicle with configuration parameters
    configuration = dk.load_config(myconfig=args['--myconfig'])
    logger.info('\nAssembling vehicle from parts...')
    vehicle = assemble(configuration)

    # start the vehicle
    logger.info('Start your engines...')
    vehicle.start(rate_hz=configuration.DRIVE_LOOP_HZ, max_loop_count=configuration.MAX_LOOPS)

    logger.info('Vehicle end.\n')

`
DocGarbanzo commented 2 years ago

Hi @Ezward and @cfox570, Thanks for that very detailed feedback, you have really understood the approach and possible problems / shortcomings and non-addressed questions. Let me address all of these items here:

1) Perhaps we can do some introspection of a part to figure out what the arguments, inputs and outputs are. All of that would be much easier if we added type annotations to the parts. _The arguments are recorded in the factory that registers all parts. They can and will be used in order to facilitate an easy construction, such that the user doesn't have to know them. Type annotations are good, but here we probably need doc strings to explain what they are meant to be. I'm figuring out how to get the docstring arguments passed through. Inputs/outputs are really just string labels to pass the data through the car. We have chosen to use some standard names like cam/image_array etc. I was not thinking of restricting them, but we could give it a thought._

2) Perhaps we simply maintain a directory of parts. This could be very simple to implement. It could be hierarchical; so we can group parts by their uses, independent of what python file they are implemented in. The directory would define the arguments, inputs and outputs and provide a lot more description that we could get simply by introspection. The parts are all registered in the factory, and through the module structure they form some hierarchy. It would be easier to keep these in sync than adding another hierarchy independently of that. I'd rather suggest to move some parts between files or directories, to create a clear structure. I think the code should be as hierarchical as the parts themselves (cameras, sensors, actuators, etc,)

3) Perhaps we define a comment block format for a part that can then be scraped to provide this information. That has the advantage that it would keep the documentation with the part. Any author of a new part could simply add this comment part to have their part incorporated into the directory. We could make this part of a part's code review. Yes

4) If we know the constructor arguments and inputs and outputs for a given part, we could create a editor that would allow the user to pick a part and drop it in the vehicle. I think about how donkey ui could support the entire work flow for donkey car, include vehicle definition and configuration. Super cool to think about. I obviously thought about this. It could be donkey UI using drop down menus, but I think we can easily reverse engineer a dot graph, so you could draw your vehicle in a graphical app, like dagitty or so (there are also interactive dot builders). We build the yaml from the dot file and then create the car from the yaml. It's possible but requires a bit of research. The donkey UI with drop downs is more straight-forward, I can do this.

5) How do we handle more than one instance of a part? You can just repeat any part as often as you want. Each entry creates an own instance (it's a list not a dict)

parts:
  - mockcamera:
      outputs: [cam/image_array_l]
      threaded: true
  - mockcamera:
      outputs: [cam/image_array_r]
      threaded: true

@cfox570 - your stuff looks fantastic and clear. It's straight forward and very similar to what I have done. There is one line in your code which doesn't make sense to me (maybe I'm not seeing it):

   part = part_class(cfg)

You seem to be passing the config to the part constructor, but most parts have dedicated arguments. However, in my factory based version I do propose to implement a create function optionally that takes the cfg as an argument. This will hide all kind of intricate lookup and iterative object constructions from the user who simply want's to create the part. Anyway, what you have done is basically the exact same idea in a bit more basic form. And what you are implying is, that with such an approach we don't need a car template python file any longer, as your code above can just be part of the donkey library. Instead a car config is a just a yaml.

Let me know if you want to join me on bringing this into Donkey Car, then I will push my branch into autorope and we can work on that together.

cfox570 commented 2 years ago

I apologize I pasted an older version. In this version,

        part = part_class(**theargs)

Here is the complete code I am currently using below.

I recommend you think about a two stage process.

  1. interim version that works with current parts. So we don't need to change everything at once. 2.Long term version that includes introspection.
#!/usr/bin/env python3
"""
Assemble and drive a donkeycar.
Parts are specified in parts.yml in the order they need to execute.

Usage:
    ymldriver.py [--myconfig=<filename>] [--model=<model>] [--type=(linear|categorical|tflite_linear|tensorrt_linear)]

Options:
    -h --help               Show this screen.
    --myconfig=filename     Specify myconfig file to use. [default: myconfig.py]
    --model=model           Path to model. The default value is MODEL_PATH in the config file
    --type=type             Type of model. The default value is DEFAULT_MODEL_TYPE in the config file

"""
import logging
import importlib
from docopt import docopt
import donkeycar as dk
import yaml

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)

#__________________________________ ASSEMBLE THE VEHICLE________________________

def assemble(cfg):
    #Initialize car
    V = dk.vehicle.Vehicle()

    # Load the YAML file specifying the parts
    with open(cfg.PARTS_PATH) as file:
        try:
            dict = yaml.safe_load(file)
        except yaml.YAMLError as exception:
            logger.error(exception)
            return None

    parts = dict.get("parts")
    if parts is None:
        logger.error("parts.yml is missing the parts key")
        raise Exception()

    for mod_name, definition in parts.items():      
        # decode definition
        theclass = None
        theargs = None
        theinputs = None
        theoutputs = None
        threaded = None
        theruncondition = None
        enable = None

        for prop, value in definition.items():
#             print(prop,value)
            if prop == 'class':
                theclass = value
            elif prop == 'args':
                for key, dat in value.items():
                    if dat[0:3] == 'cfg':
#                         print (f'{dat}: {eval(dat)}')
                        value[key] = eval(dat)
                theargs = value
            elif prop == 'inputs':
                theinputs = value
            elif prop == 'outputs':
                theoutputs = value
            elif prop == 'thread':
                threaded = value
            elif prop == 'run_condition':
                theruncondition = value
            elif prop == 'enable':
                enable = value
                if not enable:
                    break # stop processing the entry
            else:
                logger.error(f'key "{prop}" is unknown')
                raise Exception()

        if enable is None:
            logger.error(f'Definition: enable is missing ')
            raise Exception()
        if not enable:
            continue       
        if theclass is None:
            logger.error(f'Definition: the class is missing ')
            raise Exception()            
        if theargs is None:
            logger.error(f'Definition: the args are missing ')
            raise Exception()            
        if theinputs is None:
            logger.error(f'Definition: the inputs are missing ')
            raise Exception()       
        if theoutputs is None:
            logger.error(f'Definition: the outputs are missing ')
            raise Exception()   
        if threaded is None:
            threaded = False    

        # import module <mod_name>
        module = importlib.import_module(mod_name)
        logger.info(f'{module.__name__} imported.')

        #create part object from <class_name>
        part_class = getattr(module, theclass)
        part = part_class(**theargs)
        logger.info(f'    {theclass} part created') # from {type(part.super())
        # add part to vehicle
        V.add(part, inputs=theinputs, outputs=theoutputs, threaded=threaded, run_condition=theruncondition)
        logger.info(f'    ... added to vehicle')

        if theclass == 'MyJoystickController':
            part.set_deadzone(cfg.JOYSTICK_DEADZONE)
            part.print_controls()

    return V

#__________________________________ MAIN _________________________________________
if __name__ == '__main__':

    # load configuration and check command line options
    args = docopt(__doc__)   
    configuration = dk.load_config(myconfig=args['--myconfig'])
    if args['--type']:
         configuration.DEFAULT_MODEL_TYPE = args['--type']
    if args['--model']:
        configuration.MODEL_PATH = args['--model']

    # assemble vehicle with configuration parameters
    logger.info('\nAssembling vehicle from parts...')
    vehicle = assemble(configuration)
    if vehicle is not None:
        # start the vehicle
        logger.info('Start your engines...')
        vehicle.start(rate_hz=configuration.DRIVE_LOOP_HZ, max_loop_count=configuration.MAX_LOOPS)

    logger.info('Vehicle end.\n')
cfox570 commented 2 years ago

I would definitely like to help out in developing the code. Let know when you push the branch.

Ezward commented 1 year ago

@DocGarbanzo I suggest we change this issue to Allow declarative construction of vehicle