Take the 2-minute tour ×
Geographic Information Systems Stack Exchange is a question and answer site for cartographers, geographers and GIS professionals. It's 100% free, no registration required.

Whuber provided an answer at Producing building shadows? which required using Avenue code.

Any idea how to make it work in ArcGIS Desktop 10?

share|improve this question
2  
You can get the centroid of the building foot print with arcpy then implement some logic to move it a bit over and over again until it fits some criteria. More details about specifics may be helpful. –  Justin Feb 7 '12 at 19:33
    
There is a great answer to your previous question that you should consider accepting: gis.stackexchange.com/questions/17155/… –  blah238 Feb 7 '12 at 20:05
    
@Justin That sounds like what I'm looking for. Is there a tutorial online for arcpy that you know of? I suppose I'd define the angle direction and lenngth that the copy and move would repeat for? –  EightyTwenty Feb 7 '12 at 23:01
    
@blah238 I appreciated that whuber's answer does exactly what I was asking for. However, his use of Avenue code seems to be above my aptitude. –  EightyTwenty Feb 7 '12 at 23:05
2  
This is a close call, Matt, due to the clear and close relationship between the two questions. On balance, I think the current structure is best for our site: one question asks for a (generic) method; this one asks for code on a specific platform. The first question has been asked and answered, so formulating this one as a mechanism to encourage replies--which had been requested several months ago--makes sense (although there are ways to do the same without creating a new question, such as by putting a bounty on the old one). –  whuber Feb 10 '12 at 14:26

3 Answers 3

As alluded to in the comments of my previous answer (kept intact instead of edited, for comparison purposes), performance could be a lot better with additional optimizations (using the in_memory workspace instead of using geometry objects, move GP calls outside of loops where possible) as well as by utilizing multiprocessing.

Here is another version of the script that runs much faster, especially if you use all of your machine's processors. It uses the standard multiprocessing module in Python, using the Pool construct to queue up jobs that are partitioned by row ranges and processed in parallel by a set number of processes. It is configurable to use all, some, or 1 of your CPU processors, along with specifying the row partition lengths. See the comments in the "configuration" section for more details.

Basically if you tell it to use multiprocessing, it will partition the input feature class into ranges of OIDs, create multiple intermediate shapefiles, one per range, and finally merge them at the end. It also cleans up after itself by removing the intermediate shapefiles. There is some overhead to this, but the benefit of using all of your CPU power far outweighs the overhead in my testing (getting approximately 80% scaling efficiency with a dataset of 32k features).

Notes:

  • Because it uses the in_memory workspace for the computationally intensive shadow calculations, take care to specify an appropriate partition size (procfeaturelimit) if you are sending in extremely large datasets. With the default settings the partition size will be the count of input rows divided by the number of cores, which is probably the most efficient configuration unless you start running out of memory.

  • I have had zero luck using this as a script tool in ArcMap/ArcCatalog, even when set to run out of process. It appears that cursors are extremely slow when run out of process and called from a script tool, and subprocesses never seem to get created. So I am just running it from the PyScripter remote engine or at the Windows command line. If you can get it to work as a script tool, please let me know! Edit: It does work fine as a script tool if you configure it to not use multiprocessing (cores = 1, procfeaturelimit = 0) and run it in process.


import arcpy
import os
import math
import multiprocessing
import time

############################    Configuration:    ##############################
# Specify scratch workspace
scratchws = r"c:\temp\bldgshadowtest" # MUST be a folder, not a geodatabase!

# Specify output field name for the original FID
origfidfield = "ORIG_FID"

# Specify the number of processors (CPU cores) to use (0 to use all available)
cores = 0

# Specify per-process feature count limit, tune for optimal
# performance/memory utilization (0 for input row count divided by cores)
procfeaturelimit = 0

# TIP: Set 'cores' to 1 and 'procfeaturelimit' to 0 to avoid partitioning and
# multiprocessing completely
################################################################################

def message(msg, severity=0):
    print msg

    try:
        for string in msg.split('\n'):
            if severity == 0:
                arcpy.AddMessage(string)
            elif severity == 1:
                arcpy.AddWarning(string)
            elif severity == 2:
                arcpy.AddError(string)
    except:
        pass

def getOidRanges(inputFC, oidfield, count):
    oidranges = []
    if procfeaturelimit > 0:
        message("Partitioning row ID ranges ...")
        rows = arcpy.SearchCursor(inputFC, "", "", oidfield, "%s A" % oidfield)
        minoid = -1
        maxoid = -1
        for r, row in enumerate(rows):
            interval = r % procfeaturelimit
            if minoid < 0 and (interval == 0 or r == count - 1):
                minoid = row.getValue(oidfield)
            if maxoid < 0 and (interval == procfeaturelimit - 1 or r == count - 1):
                maxoid = row.getValue(oidfield)
            if minoid >= 0 and maxoid >= 0:
                oidranges.append([minoid, maxoid])
                minoid = -1
                maxoid = -1
        del row, rows
    return oidranges

def computeShadows(inputFC, outputFC, oidfield, shapefield, heightfield, azimuth, altitude, outputSR="", whereclause=""):
    # Set outputs to be overwritten just in case; each subprocess gets its own environment settings
    arcpy.env.overwriteOutput=True

    # Create in-memory feature class for holding the shadow polygons
    tempshadows = r"in_memory\tempshadows"
    arcpy.CreateFeatureclass_management(
        "in_memory",
        "tempshadows",
        "POLYGON", "", "", "",
        outputSR)
    arcpy.AddField_management(tempshadows, origfidfield, "LONG")

    # Open a cursor on the input feature class
    searchfields = ",".join([heightfield, oidfield, shapefield])
    rows = arcpy.SearchCursor(inputFC, whereclause, "", searchfields)

    # Open an insert cursor on the in-memory feature class
    tempinscur = arcpy.InsertCursor(tempshadows)

    # Create array for holding shadow polygon vertices
    arr = arcpy.Array()

    # Compute the shadow offsets.
    spread = 1/math.tan(altitude)

    # Read the input features
    for row in rows:
        oid = int(row.getValue(oidfield))
        shape = row.getValue(shapefield)
        height = float(row.getValue(heightfield))

        # Compute the shadow offsets.
        x = -height * spread * math.sin(azimuth)
        y = -height * spread * math.cos(azimuth)

        # Copy the original shape as a new feature
        tempnewrow = tempinscur.newRow()
        tempnewrow.setValue(origfidfield, oid) # Copy the original FID value to the new feature
        tempnewrow.shape = shape
        tempinscur.insertRow(tempnewrow)

        # Compute the wall shadow polygons and insert them into the in-memory feature class
        for part in shape:
            for i,j in enumerate(range(1,part.count)):
                pnt0 = part[i]
                pnt1 = part[j]
                if pnt0 is None or pnt1 is None:
                    continue # skip null points so that inner wall shadows can also be computed

                # Compute the shadow offset points
                pnt0offset = arcpy.Point(pnt0.X+x,pnt0.Y+y)
                pnt1offset = arcpy.Point(pnt1.X+x,pnt1.Y+y)

                # Construct the shadow polygon and insert it to the in-memory feature class
                [arr.add(pnt) for pnt in [pnt0,pnt1,pnt1offset,pnt0offset,pnt0]]
                tempnewrow.shape = arr
                tempnewrow.setValue(origfidfield, oid) # Copy the original FID value to the new feature
                tempinscur.insertRow(tempnewrow)
                arr.removeAll() # Clear the array so it can be reused

    # Clean up the insert cursor
    del tempnewrow, tempinscur

    # Dissolve the shadow polygons to the output feature class
    dissolved = arcpy.Dissolve_management(tempshadows, outputFC, origfidfield).getOutput(0)

    # Clean up the in-memory workspace
    arcpy.Delete_management("in_memory")

    return dissolved

if __name__ == "__main__":
    arcpy.env.overwriteOutput=True

    # Read in parameters
    inputFC = arcpy.GetParameterAsText(0)
    outputFC = arcpy.GetParameterAsText(1)
    heightfield = arcpy.GetParameterAsText(2) #Must be in the same units as the coordinate system!
    azimuth = math.radians(float(arcpy.GetParameterAsText(3))) #Must be in degrees
    altitude = math.radians(float(arcpy.GetParameterAsText(4))) #Must be in degrees

    # Get field names
    desc = arcpy.Describe(inputFC)
    shapefield = desc.shapeFieldName
    oidfield = desc.oidFieldName

    count = int(arcpy.GetCount_management(inputFC).getOutput(0))
    message("Total features to process: %d" % count)

    #Export output spatial reference to string so it can be pickled by multiprocessing
    if arcpy.env.outputCoordinateSystem:
        outputSR = arcpy.env.outputCoordinateSystem.exportToString()
    elif desc.spatialReference:
        outputSR = desc.spatialReference.exportToString()
    else:
        outputSR = ""

    # Configure partitioning
    if cores == 0:
        cores = multiprocessing.cpu_count()
    if cores > 1 and procfeaturelimit == 0:
        procfeaturelimit = int(math.ceil(float(count)/float(cores)))

     # Start timing
    start = time.clock()

    # Partition row ID ranges by the per-process feature limit
    oidranges = getOidRanges(inputFC, oidfield, count)

    if len(oidranges) > 0: # Use multiprocessing
        message("Computing shadow polygons; using multiprocessing (%d processes, %d jobs of %d maximum features each) ..." % (cores, len(oidranges), procfeaturelimit))

        # Create a Pool of subprocesses
        pool = multiprocessing.Pool(cores)
        jobs = []

        # Get the appropriately delmited field name for the OID field
        oidfielddelimited = arcpy.AddFieldDelimiters(inputFC, oidfield)

        # Ensure the scratch workspace folder exists
        if not os.path.exists(scratchws):
            os.mkdir(scratchws)

        for o, oidrange in enumerate(oidranges):
            # Build path to temporary output feature class (dissolved shadow polygons)
            # Named e.g. <scratchws>\dissolvedshadows0000.shp
            tmpoutput = os.path.join(scratchws, "%s%04d.shp" % ("dissolvedshadows", o))

            # Build a where clause for the given OID range
            whereclause = "%s >= %d AND %s <= %d" % (oidfielddelimited, oidrange[0], oidfielddelimited, oidrange[1])

            # Add the job to the multiprocessing pool asynchronously
            jobs.append(pool.apply_async(computeShadows, (inputFC, tmpoutput, oidfield, shapefield, heightfield, azimuth, altitude, outputSR, whereclause)))

        # Clean up worker pool; waits for all jobs to finish
        pool.close()
        pool.join()

         # Get the resulting outputs (paths to successfully computed dissolved shadow polygons)
        results = [job.get() for job in jobs]

        try:
            # Merge the temporary outputs
            message("Merging temporary outputs into output feature class %s ..." % outputFC)
            arcpy.Merge_management(results, outputFC)
        finally:
            # Clean up temporary data
            message("Deleting temporary data ...")
            for result in results:
                message("Deleting %s" % result)
                try:
                    arcpy.Delete_management(result)
                except:
                    pass
    else: # Use a single process
        message("Computing shadow polygons; using single processing ...")
        computeShadows(inputFC, outputFC, oidfield, shapefield, heightfield, azimuth, altitude, outputSR)

    # Stop timing and report duration
    end = time.clock()
    duration = end - start
    hours, remainder = divmod(duration, 3600)
    minutes, seconds = divmod(remainder, 60)
    message("Completed in %d:%d:%f" % (hours, minutes, seconds))

As for performance, using the same dataset as my previous answer that originally took 1.25 hours, this version completes in 8 minutes, 30 seconds using a single process and no partitioning, and 2 minutes 40 seconds using 4 processes and 4 partitions (jobs) of ~8k features each. RAM usage is also much more reasonable, using around 250MB for a single process, and around 150MB per process for multiprocessing.

For comparison purposes, here is a test dataset we can benchmark against. It's ~22k records from the City of San Francisco's building footprints dataset (~40k polygon parts totaling 1,076,060 vertices, including one duplicate closing vertex for each ring), and I kept only one field with the height in feet of the building (prsizeh). With 4 processes it took 5 minutes and 30 seconds to complete, and with a single process it took 17 minutes, 30 seconds. I think they are fairly complex geometries because they were converted from 3D models rather than digitized from aerial photographs. (6277 features have multiple parts. One has 36 parts, many of them slivers.)

share|improve this answer
2  
Superb - thanks for posting this. I've learnt a lot from looking at your code, not only relating to using the multiprocessing module, but also in terms of improving my coding style generally. It would be interesting to know how @whuber's Avenue script performs with your test dataset... Cheers. –  JamesS Feb 15 '12 at 11:23
2  
Avenue code scales primarily according to the number of requests executed (because the overhead per request is huge). This indicates the time will be proportional to the total number of polygon vertices processed (rather than to the number of polygons). Let's look at three metrics for the AV 3 scripts: (1) Max RAM, 47 MB. (2) CPU time (single thread), 7:05 (create project, read data, create shadows, save dataset, display final results). (3) Lines of code, 17 (200+ for the optimized ArcGIS solution). That last one is related to how much of the programmer's time is needed :-). –  whuber Feb 16 '12 at 19:30
1  
Thanks for the stats! Amazing to me how the Avenue code is almost as fast single threaded as the arcpy code running 4-way multithreaded! All we need now is someone to do something similar with a FOSS solution like GDAL. –  blah238 Feb 16 '12 at 19:56

I made a few improvements to @Luke's version of the script, mainly for better performance. The GP methods really don't like being called in a tight loop; by removing an unnecessary GP call and moving the necessary one outside of the innermost loop I sped up performance by 10x at least.

This also fixes shadows not being created for inner rings, degrees are converted to radians, the original FID is stored as an attribute of the output feature for joining purposes, and progress is reported at 10% increments.

import arcpy
import os
import math

def message(msg, severity=0):
    # Adds a Message (in case this is run as a tool)
    # and also prints the message to the screen (standard output)
    #
    print msg

    # Split the message on \n first, so that if it's multiple lines,
    #  a GPMessage will be added for each line
    try:
        for string in msg.split('\n'):
            # Add appropriate geoprocessing message
            #
            if severity == 0:
                arcpy.AddMessage(string)
            elif severity == 1:
                arcpy.AddWarning(string)
            elif severity == 2:
                arcpy.AddError(string)
    except:
        pass

def main():
    arcpy.env.overwriteOutput=True

    # Read in parameters
    inputFC = arcpy.GetParameterAsText(0)
    outputFC = arcpy.GetParameterAsText(1)
    heightfield = arcpy.GetParameterAsText(2) #Must be in the same units as the coordinate system!
    azimuth = math.radians(float(arcpy.GetParameterAsText(3))) #Must be in degrees
    altitude = math.radians(float(arcpy.GetParameterAsText(4))) #Must be in degrees

    # Specify output field name for the original FID
    origfidfield = "ORIG_FID"

    # Get field names
    desc = arcpy.Describe(inputFC)
    shapefield = desc.shapeFieldName
    oidfield = desc.oidFieldName

    #Output
    message("Creating output feature class %s ..." % outputFC)
    arcpy.CreateFeatureclass_management(
        os.path.dirname(outputFC),
        os.path.basename(outputFC),
        'POLYGON', "", "", "",
        desc.spatialReference if not arcpy.env.outputCoordinateSystem else "")
    arcpy.AddField_management(outputFC, origfidfield, "LONG")
    inscur = arcpy.InsertCursor(outputFC)

    # Compute the shadow offsets.
    spread = 1/math.tan(altitude) #outside loop as it only needs calculating once

    count = int(arcpy.GetCount_management(inputFC).getOutput(0))
    message("Total features to process: %d" % count)

    searchFields = ",".join([heightfield, oidfield, shapefield])
    rows = arcpy.SearchCursor(inputFC, "", "", searchFields)

    interval = int(count/10.0) # Interval for reporting progress every 10% of rows

    # Create array for holding shadow polygon vertices
    arr = arcpy.Array()
    for r, row in enumerate(rows):
        pctComplete = int(round(float(r) / float(count) * 100.0))
        if r % interval == 0:
            message("%d%% complete" % pctComplete)
        oid = row.getValue(oidfield)
        shape = row.getValue(shapefield)
        height = float(row.getValue(heightfield))

        # Compute the shadow offsets.
        x = -height * spread * math.sin(azimuth)
        y = -height * spread * math.cos(azimuth)

        # Initialize a list of shadow polygons with the original shape as the first
        shadowpolys = [shape]

        # Compute the wall shadows and append them to the list
        for part in shape:
            for i,j in enumerate(range(1,part.count)):
                pnt0 = part[i]
                pnt1 = part[j]
                if pnt0 is None or pnt1 is None:
                    continue # skip null points so that inner wall shadows can also be computed

                # Compute the shadow offset points
                pnt0offset = arcpy.Point(pnt0.X+x,pnt0.Y+y)
                pnt1offset = arcpy.Point(pnt1.X+x,pnt1.Y+y)

                # Construct the shadow polygon and append it to the list
                [arr.add(pnt) for pnt in [pnt0,pnt1,pnt1offset,pnt0offset,pnt0]]
                shadowpolys.append(arcpy.Polygon(arr))
                arr.removeAll() # Clear the array so it can be reused

        # Dissolve the shadow polygons
        dissolved = arcpy.Dissolve_management(shadowpolys,arcpy.Geometry())[0]

        # Insert the dissolved feature into the output feature class
        newrow = inscur.newRow()
        newrow.setValue(origfidfield, oid) # Copy the original FID value to the new feature
        newrow.shape = dissolved
        inscur.insertRow(newrow)

        del row, newrow, shadowpolys, dissolved
    del inscur, rows

if __name__ == "__main__":
    main()

Building shadows image

NOTE: Performance is still pretty bad: Roughly 1.25 hours for ~34k buildings on a fast machine, and something is leaking memory at an alarming rate -- roughly 1MB/sec -- it will max out the process's 2GB RAM limit fairly quickly at which point performance will grind to a near standstill. Using gc.collect() does not help, and it sticks around even after the script has completed, so I suspect it is the geometry objects not being released internally, as in this ESRI forum thread. To confirm this, one could adjust the script to use temporary feature classes on disk instead of geometry objects for the intermediate geometry storage.

I think that this algorithm could also benefit greatly from multiprocessing. Since the algorithm does not require features to be grouped, one could partition by rows instead of by geographic areas.

share|improve this answer
7  
+1 It is wonderful to see how this community can incrementally improve (and critically evaluate) a solution. Keep it up! (BTW, now I'll have to go resurrect that Avenue script and do some timing to see how well 20 year old technology keeps up...) –  whuber Feb 12 '12 at 0:02
7  
In ArcView 3.3 with the Avenue script: shadows for 34,000 random rectangles computed, saved to disk, and drawn in 60 seconds (Xeon 3580 @ 3.33 GHz, Win 7 x64.) Peak working set: 41 MB RAM. –  whuber Feb 12 '12 at 3:52
2  
+1, shocking. Hopefully the supposed cursor improvements in 10.1 will help out here. –  blah238 Feb 12 '12 at 5:05
2  
Is it the dissolve call that is wrecking performance? The overhead for that call is large as it is designed for larger dataset handling rather than one off calls. Perhaps write the parts to scratch (or in memory workspace) and then dissolve the whole fc in one call. –  Craig Williams Feb 15 '12 at 7:47
1  
@Craig, yes that is probably one of the main culprits is GP methods being called in a tight loop. I was calling Dissolve once per row -- the new answer I posted calls it much less frequently, depending on how the rows are partitioned. I still think that the geometry objects are what are leaking memory though. –  blah238 Feb 15 '12 at 9:59

I've set this up as a ArcToolbox type of script, rather than field calculator as whuber did.

This is pretty much a straight port of whubers Avenue code.

EDIT: the script assumes the height is stored in a field in the featureclass attribute table, not a featureclass with 3D geometries (PolygonZ)

import arcpy,os,math
arcpy.env.overwriteOutput=True

buildings = arcpy.GetParameterAsText(0)
shadows = arcpy.GetParameterAsText(1)
heightfield=arcpy.GetParameterAsText(2) #Must be in the same units as the coordinate system!
shapefield=arcpy.Describe(buildings).shapeFieldName
try:azimuth=float(arcpy.GetParameterAsText(3))
except:azimuth=200 #default
try:altitude=float(arcpy.GetParameterAsText(4))
except:altitude=35 #default

#Output
result=arcpy.CreateFeatureclass_management(os.path.dirname(shadows),os.path.basename(shadows),'POLYGON')
inscur=arcpy.InsertCursor(shadows)

# Compute the shadow offsets.
spread = 1/math.tan(altitude) #outside loop as it only needs calculating once

for row in arcpy.SearchCursor(buildings):
    shape=row.getValue(shapefield)
    height=float(row.getValue(heightfield))

    # Compute the shadow offsets.
    x = -height * spread * math.sin(azimuth)
    y = -height * spread * math.cos(azimuth)

    # Clone the original shape.
    clone=arcpy.CopyFeatures_management(shape,arcpy.Geometry())[0]

    # Adjoin the wall shadows.
    for part in shape:
        for i,j in enumerate(range(1,part.count)):
            pnt0=part[i] #This will fail if the scripts comes across a polygon with
            pnt1=part[j] #inner ring/s, to handle this case you'll need to test
                         #that each point is not None.
            if pnt1 is None:break #EDIT: now it won't fail, but you still 
                                  #don't get nice shadows from the inner walls.
            pnt0offset=arcpy.Point(pnt0.X+x,pnt0.Y+y)
            pnt1offset=arcpy.Point(pnt1.X+x,pnt1.Y+y)
            arr=arcpy.Array([pnt0,pnt1,pnt1offset,pnt0offset,pnt0])
            clone=arcpy.Union_analysis([arcpy.Polygon(arr),clone],arcpy.Geometry())
            clone=arcpy.Dissolve_management(clone,arcpy.Geometry())[0]

    newrow=inscur.newRow()
    newrow.shape=clone
    inscur.insertRow(newrow)
    del newrow,clone,arr,pnt0,pnt0offset,pnt1,pnt1offset

del inscur

Example output

share|improve this answer
1  
Nice work! I think as you mentioned in the comments that to handle polygons with donuts you'll need a little bit more logic when iterating over the points. There is an example in the help here: help.arcgis.com/en/arcgisdesktop/10.0/help/index.html#//… –  blah238 Feb 10 '12 at 3:14
    
Yeah, I was just being lazy :) –  Luke Feb 10 '12 at 3:35
    
Edited to handle exceptions caused by inner rings. It won't produce nice shadows from the inner "walls" but will just ignore them. –  Luke Feb 10 '12 at 4:49
1  
The right way is to double-loop: for each shape part, run an outer loop over the rings and keep the inner loop the same as you have it now. This creates a set of shadows for all walls, whether inner or outer. –  whuber Feb 10 '12 at 14:30
1  
There's a simpler way: use the null point as a sentinel to start over again. But building the set of rings may lead to clearer code (and will more closely parallel the pseudocode). –  whuber Feb 10 '12 at 22:05

Your Answer

 
discard

By posting your answer, you agree to the privacy policy and terms of service.

Not the answer you're looking for? Browse other questions tagged or ask your own question.