terraref / computing-pipeline

Pipeline to Extract Plant Phenotypes from Reference Data
BSD 3-Clause "New" or "Revised" License
21 stars 13 forks source link

Fix LAS -> GeoTIFF georeferencing #431

Closed max-zilla closed 6 years ago

max-zilla commented 6 years ago

https://github.com/terraref/laser3d/pull/2

In the new implementation using plyfile + laspy + pktools, the LAS file headers include offset and scale that are supposed to georeference the LAS. However, pklas2img creates a geotiff that is not correctly georeferenced.

Currently in that PR, my code uses same transformation we used for Solmaz's BMP files to rotate and georeference the TIF file to a new TIF file. But if we can properly fix LAS header and/or pklas2img conversion call, we should be able to directly generate a georeferenced GeoTIFF from LAS file and not have to worry about other georeferencing.

Ideas:

If we can solve this issue for this sprint, we should be pretty much ready to deploy updated extractors for ply2las and heightmap.

Will post a bit more in the morning with sample files and images.

max-zilla commented 6 years ago

Current progres, ran into same error @ZongyangLi observed with LAS overflows trying to convert to lat/long. So instead I'm converting to UTM with an offset. This generates the TIF but it's coarse (1m resolution) and not in the correct spot yet. Changing -dx and -dy to 0.1 (or smaller) makes the pixels smaller but does not increase sampling.

My test data is 2017-08-10__00-01-19-561. metadata.json is in raw_data/scanner3DTop, plyfiles are in Level_1/scanner3DTop, there is also raster using old code in Level_1/laser3d_heightmap to compare to.

def generate_las_from_ply(inp, out, pco):
    """
    :param inp: list of input PLY files or single file path
    :param out: output LAS file
    :param pco: point cloud origin from metadata e.g. md['sensor_variable_metadata']['point_cloud_origin_m']['east']
    """
    if not isinstance(inp, list):
        inp = [inp]

    # Create concatenated list of vertices to generate one merged LAS file
    first = True
    for plyf in inp:
        plydata = PlyData.read(plyf)
        merged_x = plydata['vertex']['x'] if first else numpy.concatenate([merged_x, plydata['vertex']['x']])
        merged_y = plydata['vertex']['y'] if first else numpy.concatenate([merged_y, plydata['vertex']['y']])
        merged_z = plydata['vertex']['z'] if first else numpy.concatenate([merged_z, plydata['vertex']['z']])
        first = False
    # PCO: {'y': 48.458000000000006, 'x': 127.55299999999998, 'z': 2.8050000000000006}

    # merged x y z
    # [-550.38256836 -549.65881348 -540.06091309 ...,  569.70611572  570.31878662  570.88928223]
    # [-4115.94775391  -4115.02734375  -4128.1796875  ..., -25900.09570312  -25900.234375   -25900.1796875 ]
    # [-55.74633789 -54.41064453 -73.07043457 ...,  -8.83422852  -9.02001953  -8.92834473]

    # Convert scanner coords to UTM
    utm_x, utm_y = scanalyzer_to_mac(
        (merged_x * 0.01) + pco['x'],
        (merged_y * 0.01) + pco['y']
    )
    utm_z = (merged_z * 0.01) + pco['z']
    # utm x y z
    # [ 409006.03125  409006.   409006.15625 ...,  409223.65625  409223.65625  409223.65625]
    # [ 3660097.    3660097.    3660097.25 ...,  3660106.5   3660106.5   3660106.5 ]
    # [ 2.24753666  2.26089358  2.07429576 ...,  2.71665788  2.71479988  2.7157166 ]

    # Create header and populate with scale and UTM-12 georeference offset
    w = laspy.base.Writer(out, 'w', laspy.header.Header())

    # https://gis.stackexchange.com/questions/185131/converting-between-coordinate-systems-for-las-file-using-laspy
    xmin = numpy.floor(numpy.min(utm_x))
    ymin = numpy.floor(numpy.min(utm_y))
    zmin = numpy.floor(numpy.min(utm_z))
    xmax = numpy.ceil(numpy.max(utm_x))
    ymax = numpy.ceil(numpy.max(utm_y))
    zmax = numpy.ceil(numpy.max(utm_z))
    xrg = xmax - xmin
    yrg = ymax - ymin
    zrg = zmax - zmin

    w.set_header_property("x_offset", xmin)
    w.set_header_property("y_offset", ymin)
    w.set_header_property("z_offset", zmin)
    safety_factor = 1
    maxval = 2e9
    w.header.scale = [safety_factor*xrg/maxval, safety_factor*yrg/maxval, safety_factor*zrg/maxval]
    print(utm_x)
    print(utm_y)
    print(utm_z)
    print(w.header.offset)
    print(w.header.scale)

    w.set_x(utm_x, True)
    w.set_y(utm_y, True)
    w.set_z(utm_z, True)
    print(w.get_points())
    w.header.update_min_max(True)
    w.close()

    return

def generate_tif_from_las(inp, out, bounds, mode='max'):
    """
    Create a raster (e.g. Digital Surface Map) from LAS pointcloud.
    :param inp: input LAS file
    :param out: output GeoTIFF file
    :param bounds: (lat min, lat max, long min, long max) (lowerY, upperY, rightX, leftX)
    :param mode: max | min | mean | median | sum (http://www.nongnu.org/pktools/html/md_pklas2img.html)

    generally:
        max = highest pixels in cell, usually canopy - equates to a DSM (Digital Surface Map)
        min = lowest pixel in a cell, usually soil - equates to DTM (Digital Terrain Map)
    """

    print('pklas2img -i '+inp+' -o '+out+' -comp '+mode+' -n z -a_srs epsg:32612')
    subprocess.call(['pklas2img -i '+inp+' -o '+out+' -comp '+mode+' -n z -a_srs epsg:32612'], shell=True)

# Pretend we parsed this from the metadata
with open(md) as jmd:
    md = clean_metadata(json.load(jmd), 'scanner3DTop')
pcoe = md['sensor_variable_metadata']['point_cloud_origin_m']['east']
pcow = md['sensor_variable_metadata']['point_cloud_origin_m']['west']
bbxe = md['spatial_metadata']['east']['bounding_box']
bbxw = md['spatial_metadata']['west']['bounding_box']
bounds_e = geojson_to_tuples(bbxe)
bounds_w = geojson_to_tuples(bbxw)
bounds_merge = (
        bounds_e[0] if bounds_e[0] < bounds_w[0] else bounds_w[0], 
        bounds_e[1] if bounds_e[1] > bounds_w[1] else bounds_w[1], 
        bounds_e[2] if bounds_e[2] < bounds_w[2] else bounds_w[2], 
        bounds_e[3] if bounds_e[3] > bounds_w[3] else bounds_w[3], 
    )

if CONVERT:
    print("east single")
    generate_las_from_ply(inpe, eastout, pcoe)
    generate_tif_from_las(eastout, eastout.replace(".las", ".tif"), bounds_e)
    print("west single")
    generate_las_from_ply(inpw, westout, pcow)
    generate_tif_from_las(westout, westout.replace(".las", ".tif"), bounds_w)      
    print("merge!")
    generate_las_from_ply([inpe, inpw], out, pcoe)
    generate_tif_from_las(out, out.replace(".las", ".tif"), bounds_merge)

screen shot 2018-04-06 at 11 23 43 am

max-zilla commented 6 years ago

OK, I think this is looking better now: screen shot 2018-04-12 at 9 58 55 am

This is 4 consecutive laser3d scans converted from PLY to LAS to TIF from 2017-08-07 (first 4 scans of the day). Among issues I solved:

So the LAS files are in the right place now. The next step was conversion to GeoTIFF. I tried using pktools for this, but pktools simply cannot render a raster below 1m resolution for some reason. 1m resolution looks like this: screen shot 2018-04-12 at 10 09 40 am ...and if you set it to, e.g. 0.1m resolution using dx/dy args for pktools you get this: screen shot 2018-04-12 at 10 10 29 am smaller pixels but not MORE pixels. Bizarre.

So back to PDAL. Found a lightweight installation method and used PDAL pipeline feature to generate raster from PDAL at 0.1 resolution, shown above. Going to 0.01 resolution has similar problem however.

https://github.com/terraref/laser3d/pull/7 This PR has the code I'm using. I brought in GMaps satellite view to see how it lines up to the field there and it seems aligned: screen shot 2018-04-12 at 10 18 23 am

max-zilla commented 6 years ago
docker run -it -v /Users/mburnette/Downloads/DATAFOLDER:/PLYTEST terraref/terrautils /bin/bash
pip install plyfile laspy terraref-laser3d
apt-get update && apt-get install -y pdal
echo SECRET_BETY_KEY >> $HOME/.betykey

That will be sufficient to run some of the functions in the laser3D PR above.

max-zilla commented 6 years ago

I wonder if the striping at high resolution (e.g. 0.01m) is a result of PDAL or pktools rounding our coordinates "under the hood" and not using enough decimal places to correctly render millimeter-level data. If we have < 7 or so significant digits we'd expect to see this snapping.

dlebauer commented 6 years ago

Won't scale and offset allow you to get below this limit? On Thu, Apr 12, 2018 at 11:32 AM Max Burnette notifications@github.com wrote:

I wonder if the striping at high resolution (e.g. 0.01m) is a result of PDAL or pktools rounding our coordinates "under the hood" and not using enough decimal places to correctly render millimeter-level data. If we have < 7 or so significant digits we'd expect to see this snapping.

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/terraref/computing-pipeline/issues/431#issuecomment-380866537, or mute the thread https://github.com/notifications/unsubscribe-auth/AAcX56e5260xIpH58YXWb9fZm0JWynNnks5tn4FYgaJpZM4THcCq .

max-zilla commented 6 years ago

Scale and offset are for LAS, which is fine. This is converting Las to Tif, it seems the way PDAL calculates pixel locations is where the truncation happens.

max-zilla commented 6 years ago

We need to create 3 follow-up tickets:

max-zilla commented 6 years ago

1) ply2las done 2) https://github.com/terraref/computing-pipeline/issues/460 3) https://github.com/terraref/computing-pipeline/issues/461