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.