hywiz

A utility toolbox for launching a Flask hyperspectral visualisation server or exporting static visualisation web apps.

 1"""
 2A utility toolbox for launching a Flask hyperspectral visualisation server or exporting
 3static visualisation web apps.
 4"""
 5
 6# really only 3 functions need to be accessible here! :-)
 7from ._flask import init, launch
 8from ._static import buildWeb
 9
10# expose these for pdoc
11__all__ = [init, launch, buildWeb]
def init(shed: hycore.coreshed.Shed):
272def init( shed : Shed ):
273    """
274    Build a flask app instance ready to be launched.
275    :param shed: The Shed to serve.
276    :return: A flask app.
277    """
278    app = Flask(__name__,
279                static_url_path='/static',
280                static_folder=jsapp.root)
281    app.config['TEMPLATES_AUTO_RELOAD'] = True
282
283    # setup HTTP requests
284    @app.route('/map', methods=['GET'])
285    @app.route('/map/', methods=['GET'])
286    @app.route('/map.json', methods=['GET'])
287    def shedmap():
288        """
289        :return: A JSON file with all the holes in this shed. Each hole will contain a list of box objects flagging
290                 their name, UID, start depth and to depth.
291        """
292        out = getShedIndexSimple(shed)
293        shed.free()  # avoid potential memory leak
294        return jsonify(out)
295
296    @app.route('/map/<hole>', methods=['GET'])
297    @app.route('/map/<hole>/', methods=['GET'])
298    @app.route('/map/<hole>.json', methods=['GET'])
299    def holemap(hole):
300        """
301        :param hole: Name of the hole to map
302        :return: A JSON file containing all boxes in the specified hole.
303        """
304        try:
305            hole = shed.getHole(hole)
306        except:
307            return abort(404)
308
309        out = getBoxesInHole(shed, hole)
310        shed.free()  # avoid potential memory leak
311        return jsonify(out)
312
313    @app.route('/map/<hole>/<box>', methods=['GET'])
314    @app.route('/map/<hole>/<box>/', methods=['GET'])
315    @app.route('/map/<hole>/<box>.json', methods=['GET'])
316    def boxmap(hole, box):
317        """
318        :param hole: Name of the hole containing the desired box.
319        :param box: Name of the box.
320        :return: A JSON file listing the available sensors, results and legends in the specified box.
321        """
322        try:
323            box = shed.getBox(hole, box)
324        except:
325            return abort(404)  # drillcore or box not found
326
327        out = getBoxContents(shed, hole, box)
328        shed.free()  # avoid potential memory leak
329        return jsonify(out)
330
331    @app.route('/map/index')
332    @app.route('/map/index/')
333    @app.route('/map/index.json')
334    def index():
335        """
336        Return a json file containing an index of this entire shed. This is structured as follows:
337
338        {
339           hole_1 = {
340                depths = { pole = [....], fence = [....] }, // depths of each pixel in pole and fence mosaics
341                ticks = { pole = [...], fence = [...] }, // depth ticks (in pixels) for each pole and fence mosaic
342                box_1 = {
343                    start = <start_depth>;
344                    end = <end_depth>;
345                    sensors = {...};
346                    results = { ... };
347                },
348                ...
349                box_n = { ... }
350           }
351        }
352        """
353        out = getShedIndexComplete(shed)
354        shed.free()  # avoid potential memory leak
355        return jsonify(out)
356
357    @app.route('/map/index.js')
358    def indexJS():
359        """
360        Get Shed index as a javascript file that declares the data variable. Mirrors functionality
361        used by static apps to access data in .json format.
362        """
363        out = getShedIndexJS(shed, compress=True)
364        shed.free()
365        return Response( out, mimetype='text/javascript')
366
367    @app.route('/leg/<legend>', methods=['GET'])
368    @app.route('/leg/<legend>/', methods=['GET'])
369    def get_legend( legend ):
370        if "." not in legend:
371            legend = legend + ".png"
372        pth = glob.glob( os.path.join( shed.getDirectory(), '**/%s'%legend), recursive=True )
373        if len(pth) > 0:
374            return send_file(pth[0])
375        return abort(404)
376    
377    @app.route('/img/<hole>/pole/<image>', methods=['GET'])
378    @app.route('/img/<hole>/pole/<image>/', methods=['GET'])
379    def get_pole_mosaic(hole, image):
380        try:
381            hole = shed.getHole(hole, create=False )
382        except:
383            return abort(404)  # hole not found
384
385        if '.' not in image:
386            image = image+".png"
387        try:
388            pth = os.path.join(hole.results.pole.getDirectory())
389            pth = os.path.join(pth, image)
390        except:
391            return abort(404)  # mosaic not found
392        shed.free()  # avoid potential memory leak
393        return send_file(pth)  # send image :-)
394
395    @app.route('/img/<hole>/<box>/spectra/<sensor>_lib.png')
396    def getSpectraLibrary(hole, box, sensor):
397        try:
398            lib = shed.getHole(hole).getBox(box).spectra.get('%s_lib'%sensor)
399        except:
400            return abort(404)
401
402        # serve as PNG image
403        from PIL import Image
404        import io
405        data = np.clip( np.transpose( lib.data, (2,0,1) ) * 255, 0, 255 ).astype(np.uint8)
406        img = Image.fromarray(data)
407        file_object = io.BytesIO()
408        img.save(file_object, 'PNG')
409        file_object.seek(0)
410        return send_file(file_object, mimetype='image/PNG')
411    
412    @app.route('/img/<hole>/<box>/spectra/<sensor>_idx.png')
413    def getSpectraIndex(hole, box, sensor):
414        try:
415            idx = shed.getHole(hole).getBox(box).spectra.get('%s_idx'%sensor)
416        except:
417            return abort(404)
418        
419        # serve as PNG image
420        from PIL import Image
421        import io
422        data= (np.clip( idx.data, 0, 255) * 255 ).astype(np.uint8)
423        img = Image.fromarray(data[...,0].T,'L')
424        file_object = io.BytesIO()
425        img.save(file_object, 'PNG')
426        file_object.seek(0)
427        return send_file(file_object, mimetype='image/PNG')
428    
429    @app.route('/img/<hole>/fence/<image>', methods=['GET'])
430    @app.route('/img/<hole>/fence/<image>/', methods=['GET'])
431    def get_fence_mosaic(hole, image):
432        try:
433            hole = shed.getHole(hole, create=False)
434        except:
435            return abort(404)  # hole not found
436
437        if '.' not in image:
438            image = image + ".png"
439        try:
440            pth = os.path.join(hole.results.fence.getDirectory())
441            pth = os.path.join(pth, image)
442        except:
443            return abort(404)  # mosaic not found
444        shed.free()  # avoid potential memory leak
445        return send_file(pth)  # send image :-)
446
447    @app.route('/img/<hole>/<box>/<image>', methods=['GET'])
448    @app.route('/img/<hole>/<box>/<image>/', methods=['GET'])
449    def get_PNG(hole, box, image):
450        """
451        :return: Serve the requested PNG file from the box directory, using the URL: img/<hole>/<box>/<image>.png
452                 If the image is not found in the box directory, we look in the results directory.
453        """
454        try:
455            box = shed.getBox(hole, box)
456        except:
457            return abort(404)  # drillcore or box not found
458
459        if '.' not in image:
460            image = image + ".png"  # default to png files
461
462        if os.path.exists(os.path.join(box.getDirectory(), image)):
463            # serve file from box directory
464            pth = os.path.join(box.getDirectory(), image)
465        elif os.path.exists(os.path.join(box.results.getDirectory(), image)):
466            # serve file from results directory
467            pth = os.path.join(box.results.getDirectory(), image)
468        else:
469            return abort(404)  # drillcore or box not found
470
471        shed.free()  # avoid potential memory leak
472        return send_file(pth)  # send image :-)
473
474    @app.route('/img/<hole>/<box>/results/<image>', methods=['GET'])
475    @app.route('/img/<hole>/<box>/results/<image>/', methods=['GET'])
476    def get_ResultsPNG(hole, box, image):
477        """
478        Serve a results png (wrapper)
479        """
480        return get_PNG(hole, box, image)
481    
482
483    # serve core React app files
484    @app.route('/static/js/<path:path>')
485    def appjs( path ):
486        return send_from_directory( os.path.join(jsapp.static_pth,'js'), path)
487    @app.route('/static/css/<path:path>')
488    def appcss( path ):
489        return send_from_directory( os.path.join(jsapp.static_pth,'css'), path)
490    @app.route('/', defaults={'path': ''})
491    @app.route('/<path:path>')
492    def serve(path):
493        print(jsapp.root, path)
494        if path != "" and os.path.exists(jsapp.root + '/' + path):
495            return send_from_directory(jsapp.root, path)
496        else:
497            return send_from_directory(jsapp.root, 'index.html')
498    
499    @app.route('/whs', methods=['POST'])
500    @app.route('/whs/', methods=['POST'])
501    def whs():
502        """
503        Process a web-hyperspectral query. This must be passed as a json object with the following format:
504
505        `let request = { hole : <hole name>,
506                     box : <box name> ,
507                     sensor : <sensor name>,
508                     operation : <operation string>,
509                     [ x : 0, y : 0 ], # defaults if operation = 'probe'
510                     [ vmin : 2, vmax : 98, method : "percent", tscale : False ] # defaults for false color normalisation
511                     }`
512
513        The `operation string` determines the data that will be returned, and should match the syntax defined by
514        `hylite.HyData.eval( ... )`. For example, `b10+b9 | b12:b15 | b5/b6` would return a 3-band false colour image
515        with R = band 10 + band 9, green = average( band 12 to band 15) and blue = band 5 / band 6. The `vmin`, `vmax`
516        and `tscale` options control normalisation to a 0-255 uint png.
517
518        Alternatively, operation can be "probe", in which case a JSON file containing the spectral profile
519        (and associated wavelengths) will be returned. In this case, the request must also include an x and y field.
520
521
522        :return:
523        """
524        data = request.json
525
526        try:
527            # get data from JSON request
528            hole = data['hole']
529            box = data['box']
530            sensor = data['sensor']
531            op = data['operation']
532
533            if 'probe' in op.lower():
534                x = data.get('x', 0)
535                y = data.get('y', 0)
536            else:
537                vmin = data.get('vmin', 2)
538                vmax = data.get('vmax', 2)
539                tscale = data.get('tscale', False)
540                method = data.get('method', 'percent')  # clip method, can be "percent" or "absolute"
541                if "abs" in method.lower():  # absolute values [ use float as per hylite notation ]
542                    vmin = float(vmin)
543                    vmax = float(vmax)
544                else:  # percentiles [ use int as per hylite notation ]
545                    vmin = int(vmin)
546                    vmax = int(vmax)
547        except:
548            return "Invalid query JSON", 400
549
550        try:
551            # load dataset
552            box = shed.getBox(hole, box)
553            data = box.get(sensor)
554        except:
555            return "Box does not exist", 400
556
557        # get a pixel spectra
558        if 'probe' in op.lower():
559            out = {}
560            out['wavelength'] = list(data.get_wavelengths().astype(float))
561            out['units'] = 'nm'
562            out['R'] = list(data.data[int(x), int(y), :].astype(float))
563            return jsonify(out)
564
565        # get a false colour image or band ratio
566        else:
567            # try:
568            result = data.eval(op)  # evaluate result
569            # except:
570            #    return "Invalid operation", 400
571
572            # apply normalisation
573            if isinstance(vmin, int) and isinstance(vmax, int):
574                result.percent_clip(vmin, vmax, per_band=tscale)
575            else:
576                result.data = (result.data - vmin) / (vmax - vmin)
577            result.data = np.clip(result.data * 255, 0, 255).astype(np.uint8)
578            if result.band_count() == 1:
579                result.data = np.dstack([result.data] * 3)
580            if result.band_count() > 3:
581                result.data = result.data[..., :3]
582
583            box.free()  # avoid possible memory leaks
584            shed.free()  # avoid possible memory leaks
585
586            # serve as PNG image
587            from PIL import Image
588            import io
589            img = Image.fromarray(result.data)
590            file_object = io.BytesIO()
591            img.save(file_object, 'PNG')
592            file_object.seek(0)
593
594            return send_file(file_object, mimetype='image/PNG')
595
596    return app

Build a flask app instance ready to be launched.

Parameters
  • shed: The Shed to serve.
Returns

A flask app.

def launch(shed: hycore.coreshed.Shed, https=False, port=5555, host='0.0.0.0'):
598def launch( shed : Shed, https=False, port=5555, host="0.0.0.0" ):
599    """
600    Launch a hywiz server that serves HSI data from specified shed (with bubbles!)
601
602    :param shed: The Shed directory to serve.
603    :param https: True if an adhoc ssl context should be used to simulate https.
604    """
605    app = Flask(__name__)
606    app.config['TEMPLATES_AUTO_RELOAD'] = True
607
608    # init app
609    app = init( shed )
610
611    # run it
612    if https:
613        app.run(ssl_context='adhoc', port=port, host=host)
614    else:
615        app.run(port=port, host=host)

Launch a hywiz server that serves HSI data from specified shed (with bubbles!)

Parameters
  • shed: The Shed directory to serve.
  • https: True if an adhoc ssl context should be used to simulate https.
def buildWeb( shed, *, compile=True, clean=True, sensors: list = None, results: dict = None, mosaic_step: int = 1, tray_step: int = 1, crop: bool = False, vb=True, **kwds):
177def buildWeb(shed, *, compile=True, clean=True, sensors : list = None, results : dict = None, 
178             mosaic_step : int = 1, tray_step : int = 1, crop : bool = False, vb=True, **kwds):
179    """
180    Build a web output for the given shed using default settings.
181    :param shed: The shed to convert to a web visualisation.
182    :param compile: True if the resulting static site should be compiled into a cross-platform runnable redbean file. Default is True. 
183    :param clean: If True (default) the directory used to assmble the redbean app is deleted. Thas has no effect if compile is False.
184    :param sensors: A list of sensor names to export to the web directory. If None (default) all sensors will be exported.
185    :param results: A dict with result names to export (keys) and corresponding legend names (values). To disable, pass an empty list.
186    :param mosaic_step: Downsampling factor for mosaic images to reduce file size. Default is 1 (no downsampling).
187    :param tray_step: Downsampling factor for tray images to reduce file size. Default is 1 (no downsampling).
188    :param crop: Crop trays to masked areas to reduce file size. Default is False.
189    :param vb: True if print outputs should be created.
190    :keywords: keywords are all passed to copyImages.
191
192    :return: A path to the index.html file.
193    """
194
195    # create output directory
196    web, img = getWebDir(shed, setup=True)
197
198    # copy images
199    nimg, sensors, results = copyImages(shed, img, sensors, results, 
200                                        mosaic_step=mosaic_step, 
201                                        tray_step=tray_step, crop=crop,**kwds )
202    if vb:
203        print("Copied %d images to output directory (%s)." % (nimg, img))
204        print("\t Output sensors are: %s" % sensors)
205        if len(results) > 0:
206            print("\t Output results are: ")
207            for k,v in results.items():
208                print("\t\t %s (legend: %s)" % (k,v))
209
210    # copy html data
211    out = copyWeb( shed, web, sensors, results, js=True, 
212                                        mosaic_step=mosaic_step, 
213                                        tray_step=tray_step, crop=crop )
214
215    bean = os.path.join( os.path.dirname( web ), "%s.bean.exe.command"%shed.name )
216    if compile:
217        # and combine everything into a funky redbean thingy!!
218        with zipfile.ZipFile(bean, 'a') as zf:
219            for f in glob.glob(os.path.join(web,'**/*.*'), recursive=True):
220                if (os.path.isfile(f)) \
221                    and ('.lua' not in f) \
222                        and ('__' not in f):
223                        zf.write(f,os.path.join( '/hywiz', os.path.relpath(f,web)) )
224            zf.write(os.path.join(web,'init.lua'),'/.init.lua') # also copy init file
225        
226        # set as executable file (unix)
227        os.chmod(bean, 0o555) 
228
229        # and remove web directory
230        if clean:
231            shutil.rmtree(web)
232
233        return bean
234    else:
235        # remove redbean file
236        os.remove( bean )
237        return out

Build a web output for the given shed using default settings.

Parameters
  • shed: The shed to convert to a web visualisation.
  • compile: True if the resulting static site should be compiled into a cross-platform runnable redbean file. Default is True.
  • clean: If True (default) the directory used to assmble the redbean app is deleted. Thas has no effect if compile is False.
  • sensors: A list of sensor names to export to the web directory. If None (default) all sensors will be exported.
  • results: A dict with result names to export (keys) and corresponding legend names (values). To disable, pass an empty list.
  • mosaic_step: Downsampling factor for mosaic images to reduce file size. Default is 1 (no downsampling).
  • tray_step: Downsampling factor for tray images to reduce file size. Default is 1 (no downsampling).
  • crop: Crop trays to masked areas to reduce file size. Default is False.
  • vb: True if print outputs should be created. :keywords: keywords are all passed to copyImages.
Returns

A path to the index.html file.