Stay Connected with the Boundless Blog

GeoScript Open Source Styling with Python

NYC DoITT

NYC DoITT, the New York City Department of Information Technology and Telecommunications, oversees the use of existing and emerging technologies in government operations and delivers various services to city residents. Since we have an office in NYC, we’re particularly proud to support them as OpenGeo Suite customers.

The Issue

Not long ago, they asked for ways to create styles based on the number of hours that had elapsed since a feature was created, a calculation that would otherwise have been impossible using a regular SLD. In a previous support story, we described using WPS in SLD to alter a geometry prior to rendering. While some rendering transforms — such as heatmaps and contour maps — are relatively well-known, GeoServer facilitates the use of Python or other scripting languages to help style data. By writing the logic in Python, NYC DoITT would be able to use the standard datetime module to style features. Below we’ll review how to use GeoScript to perform more advanced styling programmatically.

Installing GeoScript

Before we start, we will need to install the right extensions to OpenGeo Suite, particularly the GeoServer Python scripting extension. On Linux, you can simply install the geoserver-script package. On Windows, scripting is an option in the OpenGeo Suite installer.

On OSX, you will need to manually install the plugin by downloading the community build, selecting “Open Webapps Directory” from the menu, and dropping the .jar into geoserver/WEB-INF/lib.

Deploying scripts

Once GeoScript is installed, we can add our Python scripts to the scripts/functions directory in the GeoServer data directory. A simple test is to create a script named random_colour.py that returns a random colour:

import random

def run(value, args):
 return random.choice(["#000000", "#ff0000", "#00ff00", "#0000ff"])

We can then create an SLD that calls this function. The following SLD is based on the default polygon style in GeoServer, with one line changed:

<?xml version="1.0" encoding="ISO-8859-1"?>
<StyledLayerDescriptor version="1.0.0"
 xsi:schemaLocation="http://www.opengis.net/sld StyledLayerDescriptor.xsd"
 xmlns="http://www.opengis.net/sld"
 xmlns:ogc="http://www.opengis.net/ogc"
 xmlns:xlink="http://www.w3.org/1999/xlink"
 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <NamedLayer>
    <Name>geoscript test</Name>
    <UserStyle>
      <FeatureTypeStyle>
        <Rule>
          <Name>random colour</Name>
          <PolygonSymbolizer>
            <Fill>
              <CssParameter name="fill">
                <ogc:Function name='random_colour'/>
              </CssParameter>
            </Fill>
            <Stroke>
              <CssParameter name="stroke">#000000</CssParameter>
              <CssParameter name="stroke-width">1</CssParameter>
            </Stroke>
          </PolygonSymbolizer>
        </Rule>
      </FeatureTypeStyle>
    </UserStyle>
  </NamedLayer>
</StyledLayerDescriptor>

Instead of referencing a fixed grey colour, <ogc:Function name='random_colour'/> calls random_colour.py and uses the evaluated value as the polygon fill colour. The Python code simply selects at random from a list of colors — in this case: #000000, #ff0000, #00ff00 and #0000ff — and then returns it.

We can test this new style with the default opengeo:countries layer that ships with OpenGeo Suite. Every time a new request is made, the polygon colours should be randomly generated again.

 

Using Arguments

To make our SLD functions more useful, we can style based on attribute values by passing arguments to the function. Let’s create a new Python script to manage the values of each colour by mapping the value of the mapcolor9 attribute of the opengeo:countries to a colour. We will call this script colour_9.py and put it in the same scripts/functions directory:

def run(value, args):
  colour = args[0]

  if colour == 1:
    return "#fbb4ae"
  if colour == 2:
    return "#b3cde3"
  if colour == 3:
    return "#ccebc5"
  if colour == 4:
    return "#decbe4"
  if colour == 5:
    return "#fed9a6"
  if colour == 6:
    return "#ffffcc"
  if colour == 7:
    return "#e5d8bd"
  if colour == 8:
    return "#fddaec"
  if colour == 9:
    return "#f2f2f2"

This will use the value of the first argument as the colour for the feature. Our SLD needs to call this function and pass the mapcolor9 attribute as the argument:

<CssParameter name="fill">
  <ogc:Function name='colour_9'>               
    <ogc:PropertyName>mapcolor9</ogc:PropertyName>
  </ogc:Function>
</CssParameter>

While this could be done in pure SLD, it can be easier to manage complex styling rules using scripts rather than XML.

Logging to geoserver.log

Any print statements in the Python script will appear in the GeoServer logs. Let’s modify our SLD to add a second argument:

<CssParameter name="fill">
  <ogc:Function name='colour_9'>               
    <ogc:PropertyName>mapcolor9</ogc:PropertyName>
    <ogc:PropertyName>name</ogc:PropertyName>
  </ogc:Function>
</CssParameter>

Then let’s print out all our arguments from the script:

def run(value, args):
  print args
  colour = args[0]

  …

We should now see lines like the following in geoserver.log:

[2.0, Aruba]
[6.0, Angola]
[6.0, Anguilla]
[1.0, Albania]
[4.0, Aland]
[1.0, Andorra]
[3.0, United Arab Emirates]
[2.0, Armenia]
[5.0, Antigua and Barbuda]
[3.0, Austria]
[5.0, Azerbaijan]
[5.0, Burundi]
[1.0, Belgium]
[2.0, Benin]
[5.0, Burkina Faso]
[1.0, Bulgaria]
[1.0, Bahrain]
…

Using feature geometries

Attentive readers will have noted that in addition to args, our scripts have an additional value parameter. This variable contains an instance of GeoScript’s org.geotools.feature.simple.SimpleFeatureImpl. This means we have access to the feature’s geometry and, since these geometries are JTS objects, we can use methods such as getArea() in our script:

def run(value, args):
  geom = value.getDefaultGeometry()
  print "%s: %f" % (args[1], geom.getArea())

  …

The modified script above will simply record the area of each geometry in the GeoServer logs before styling it.

Scale-dependent styling

DoITT also identified scale-dependent styling as another use for GeoScript. Rather than writing filter functions in our SLD, we can instead pass our current scale as an argument to a new function and use this to format the label. Our script, country_label.py, can use the current scale to decide which of the three possible labels to use:

def run(value, args):
  abbrev = args[0]
  normal = args[1]
  formal = args[2]
  scale  = args[3]

  if scale > 10000000:
    return abbrev
  if scale < 5000000:
    return formal
  return normal

The three arguments are an abbreviated name, a regular name and a formal (long) name for each country. The corresponding SLD appears as:

<TextSymbolizer>
  <Label>
    <ogc:Function name='country_label'>      
      <ogc:PropertyName>abbrev</ogc:PropertyName>    
      <ogc:PropertyName>name</ogc:PropertyName>
      <ogc:PropertyName>formal_en</ogc:PropertyName>
      <ogc:Function name="env">
        <ogc:Literal>wms_scale_denominator</ogc:Literal>
      </ogc:Function>
    </ogc:Function>
  </Label>
  <Font>
    <CssParameter name="font-family">DejaVuSans</CssParameter>
    <CssParameter name="font-size">10.0</CssParameter>
    <CssParameter name="font-style">normal</CssParameter>
    <CssParameter name="font-weight">normal</CssParameter>
  </Font>
  <LabelPlacement>
    <PointPlacement>
      <AnchorPoint>
        <AnchorPointX>0.5</AnchorPointX>
        <AnchorPointY>0.5</AnchorPointY>
      </AnchorPoint>
    </PointPlacement>
  </LabelPlacement>
  <Halo>
    <Radius>2.0</Radius>
    <Fill>
      <CssParameter name="fill">#FFFFFF</CssParameter>
    </Fill>
  </Halo>
  <Fill>
    <CssParameter name="fill">#000000</CssParameter>
  </Fill>
</TextSymbolizer>

Once again, we could have written these rules directly into our SLD, but using GeoScript allows us to maintain complex styling more efficiently.

Adding Nuance

We might want to combine our scale-dependent rule with the area calculation so that we do not have instances of large countries with the abbreviated labels (as in the case of France being labelled Fr. in the image above):

def run(value, args):
  abbrev = args[0]
  normal = args[1]
  formal = args[2]
  scale  = args[3]
  area   = value.getDefaultGeometry().getArea()

  if scale > 10000000 and area < 5000:
    return abbrev
  if scale < 5000000:
    return formal
  return normal

Now, countries with a large area will use the country name rather than the abbreviations that we saw in the earlier example. Smaller countries, such as most of those in the Balkans, still have their names abbreviated.

Filters

Another use of GeoScript in SLDs is creating filter functions. To continue the example of using country names, we might want to find all countries whose formal designations are comprised of five or more separate words. In this case, Federal Republic of Germany would not be shown on our map, but Independent State of Papua New Guinea would.

 

Using Python, we can create a file called long_names.py with this relatively simple calculation:

def run(value, args):
  formal  = args[0]

  if len(formal.split()) >= 5:
    return True
  return False

We can then use this filter in our SLD by adding a filter after our rule name:

<Rule>
  <Name>long country names</Name>
  <ogc:Filter>
    <ogc:PropertyIsEqualTo>
      <ogc:Function name="long_names">               
        <ogc:PropertyName>formal_en</ogc:PropertyName>
      </ogc:Function>
      <ogc:Literal>true</ogc:Literal>
    </ogc:PropertyIsEqualTo>
  </ogc:Filter>
  ...
</Rule>

When the script returns True, the feature will be displayed.

countries-long-names.png

Conclusion

Organisations like NYC DoITT choose to use GeoScript because it allows them to do complex time calculations which would have been impossible in SLD but are simple in Python or JavaScript. There are other similar applications for dealing with complex data types; however, incorporating GeoScript can also make regular styling rules more concise and therefore easier to understand and maintain.

Benjamin Trigona-Harany leads our global support team from our offices in Victoria, BC. Interested in support or training for your enterprise? Contact us to learn more.