From d75efa630b3833cc16ab50e205a847ce8a636887 Mon Sep 17 00:00:00 2001 From: Joel Collins Date: Mon, 12 Nov 2018 16:22:49 +0000 Subject: [PATCH] Added capture and capture previews to web UI --- openflexure_microscope/api/static/main_v1.js | 148 +++++++++++++++--- .../api/static/style_v1.css | 8 +- .../api/templates/index_v1.html | 23 ++- openflexure_microscope/api/v1.py | 74 ++++++++- openflexure_microscope/camera/capture.py | 29 ++++ 5 files changed, 252 insertions(+), 30 deletions(-) diff --git a/openflexure_microscope/api/static/main_v1.js b/openflexure_microscope/api/static/main_v1.js index b9c115c3..1f611a66 100644 --- a/openflexure_microscope/api/static/main_v1.js +++ b/openflexure_microscope/api/static/main_v1.js @@ -1,5 +1,17 @@ -// TODO: Remember a position and restore that position -// TODO: Right click to auto-focus, centered on that region +/* +TODO: New capture panel +TODO: Make delete button work + +TODO: Recordings list +TODO: New recording panel +TODO: Somehow have Python make funky thumbnails for videos? + +TODO: Remember a position and restore that position +TODO: Right click to auto-focus, centered on that region + +TODO: Box to specify device IP +TODO: Dynamically add stream image src from JS (allows streaming from an external IP) +*/ var baseURI = "/api/v1" @@ -42,7 +54,36 @@ function getCaptures() { } function updateCaptures(response) { - console.log(response) + // Clear captures list + var capturesNode = document.getElementById("captures"); + while (capturesNode.firstChild) { + capturesNode.removeChild(capturesNode.firstChild); + } + + // For each capture in the response array + response.forEach(function(element) { + // Store the capture object URI + element_uri = `${baseURI}/capture/${element.id}` + + // Generate inner HTML from capture object + html = ` +
+
+
+ ${element.file}
+ View Download Delete JSON +
+
+
+ ` + + // Create a new capture div, and append HTML + var div = document.createElement("div"); + div.className = "capture"; + div.innerHTML = html; + capturesNode.appendChild(div); + }); + } function updateTextBoxes() { @@ -56,9 +97,9 @@ function updateTextBoxes() { } function updateStagePositions(response) { - document.getElementById('x_abs').value = response["x"]; - document.getElementById('y_abs').value = response["y"]; - document.getElementById('z_abs').value = response["z"]; + document.getElementById('x_abs').value = response.x; + document.getElementById('y_abs').value = response.y; + document.getElementById('z_abs').value = response.z; } function getStagePositions() { @@ -69,13 +110,79 @@ function getStagePositions() { safeRequest("GET", baseURI+"/position", null, updatePositionCallback, false) } +// Capture methods +function newCapture(filename, keep_on_disk, use_video_port, resizeWidth=null, resizeHeight=null) { + // Make a position request + function newCaptureCallback(response, status) { + if (status != 200) { + alert("Capture failed."); + } + console.log(status, response); + getCaptures(); // Update full list of captures when done + } + + payload = { + "filename": filename, + "keep_on_disk": keep_on_disk, + "use_video_port": use_video_port + } + + if ((resizeWidth) && (resizeHeight)) { + payload.size = { + "width": resizeWidth, + "height": resizeHeight + } + } + + console.log(payload); + safeRequest("POST", baseURI+"/capture/", payload, newCaptureCallback); +} + +function newCaptureFromInput() { + captureFilenameInput = document.getElementById('captureFilenameInput'); + captureKeepOnDiskCheck = document.getElementById('captureKeepOnDiskCheck'); + captureFullResolutionCheck = document.getElementById('captureFullResolutionCheck'); + captureResizeCheck = document.getElementById('captureResizeCheck'); + captureWidthInput = document.getElementById('captureWidthInput') + captureHeightInput = document.getElementById('captureHeightInput') + + // Get filename if one is given + if (captureFilenameInput.value === "") { + filename = null; + } + else { + filename = captureFilenameInput.value; + } + + if (captureResizeCheck.checked) { + resizeWidth = Number(captureWidthInput.value); + resizeHeight = Number(captureHeightInput.value); + } + else { + resizeWidth = null; + resizeHeight = null; + } + + newCapture(filename, captureKeepOnDiskCheck.checked, !(captureFullResolutionCheck.checked), resizeWidth, resizeHeight); + +} + +function newCaptureResizeToggle(checkbox) { + captureWidthInput = document.getElementById('captureWidthInput') + captureHeightInput = document.getElementById('captureHeightInput') + + captureWidthInput.disabled = !(checkbox.checked) + captureHeightInput.disabled = !(checkbox.checked) +} + +// Move stage methods // TODO: Combine setStagePositions and moveStagePositions, with bool argument for 'absolute' function setStagePositions(x_abs, y_abs, z_abs) { // Make a position request function absMoveCallback(response, status) { - if (status == 400) { - alert("Stage cannot be moved further than the safe range."); + if (status != 200) { + alert("Error moving stage."); } console.log(status, response); updateStagePositions(response); @@ -86,8 +193,8 @@ function setStagePositions(x_abs, y_abs, z_abs) { function moveStagePositions(x_rel, y_rel, z_rel) { // Make a position request function relMoveCallback(response, status) { - if (status == 400) { - alert("Stage cannot be moved further than the safe range."); + if (status != 200) { + alert("Error moving stage."); } console.log(status, response); updateStagePositions(response); @@ -131,9 +238,9 @@ function clickHotspotImage(event) { window.addEventListener('wheel', function(e) { multiplier = 1/100; z_delta = e.deltaY * multiplier * focusVelocity; - if ((document.getElementById("scrollFocusCheck").checked == true) && (e.target.parentNode.classList["value"] == "column middle")) { + if ((document.getElementById("scrollFocusCheck").checked == true) && (e.target.parentNode.classList.value == "column middle")) { console.log(z_delta); - console.log(e.target.parentNode.classList["value"]); + console.log(e.target.parentNode.classList.value); // Make a position request safeRequest("POST", baseURI+"/position", { "absolute": false, "z": z_delta}, keyMoveCallback) } @@ -196,21 +303,21 @@ function safeRequest(method, url, payload, callback, lock=true, force=false) { var xhr = new XMLHttpRequest(); // new HttpRequest instance xhr.open(method, url); - if (lock == true) { - requestLock = true; + if (lock == true) { // If request is using the lock + requestLock = true; // Hold the lock } xhr.onload = function (e) { if (xhr.readyState === 4) { + if (lock == true) { // If request is using the lock + requestLock = false; // Release the lock + } + if (xhr.status !== 200) { - console.error(xhr.statusText); + console.error(xhr.statusText); } callback(JSON.parse(xhr.responseText), xhr.status); } - - if (lock == true) { - requestLock = false; - } }; if (method == "POST" || method == "PUT") { @@ -221,6 +328,7 @@ function safeRequest(method, url, payload, callback, lock=true, force=false) { } else { - console.log("Cannot start new request while previous requets is pending...") + console.log("Cannot start the following request while the previous requets is pending:") + console.log(method, url, payload) } } diff --git a/openflexure_microscope/api/static/style_v1.css b/openflexure_microscope/api/static/style_v1.css index bc085f34..a21f7573 100644 --- a/openflexure_microscope/api/static/style_v1.css +++ b/openflexure_microscope/api/static/style_v1.css @@ -10,7 +10,7 @@ body { } /* Preview sizing */ -img { +.middle img { width: 100%; height: 100%; object-fit: contain @@ -68,8 +68,14 @@ img { #captures { font-size:12px; + margin-top: 20px; } .capture { background-color: #d3d3d3; + margin-bottom: 5px; +} + +.capture img { + width: 60px; } \ No newline at end of file diff --git a/openflexure_microscope/api/templates/index_v1.html b/openflexure_microscope/api/templates/index_v1.html index 46ff4972..f9be076c 100644 --- a/openflexure_microscope/api/templates/index_v1.html +++ b/openflexure_microscope/api/templates/index_v1.html @@ -28,14 +28,14 @@
FOV width:
- +
FOV height:
- +

@@ -72,16 +72,25 @@
+

Capture

+ Filename:
+
+ Store on Pi:
+ Full resolution:
+ Resize capture:
+ +
+ + +

Captures

-
-

Not yet implemented.

-
Capture 1
-
+ +
- +
diff --git a/openflexure_microscope/api/v1.py b/openflexure_microscope/api/v1.py index a4c07276..009435cc 100644 --- a/openflexure_microscope/api/v1.py +++ b/openflexure_microscope/api/v1.py @@ -14,7 +14,7 @@ import datetime from flask import ( Flask, render_template, Response, - redirect, request, jsonify, send_file) + redirect, request, jsonify, send_file, abort) import numpy as np @@ -119,8 +119,10 @@ def position(): return jsonify(microscope.state['position']) + # Capture routes -@app.route(uri('/capture'), methods=['GET', 'POST', 'PUT']) + +@app.route(uri('/capture/'), methods=['GET', 'POST', 'PUT']) def capture(): """Return JSONified microscope state""" global microscope @@ -173,5 +175,73 @@ def capture(): return jsonify(metadata_array) + +@app.route(uri('/capture//'), methods=['GET', 'DELETE']) +def get_capture(capture_uuid): + """Return JSONified capture by UUID""" + global microscope + + print(capture_uuid) + capture_obj = microscope.camera.image_from_id(capture_uuid) + + if not capture_obj: + return abort(404) # 404 Not Found + + # Get capture metadata + capture_metadata = capture_obj.metadata + + # Add API routes to returned metadata + uri_dict = { + 'uri': {'metadata': uri('/capture/{}/'.format(capture_uuid))} + } + + # If available, also add download link + if capture_metadata['available']: + uri_dict['uri']['download'] = uri('/capture/{}/download'.format(capture_uuid)) + + capture_metadata.update(uri_dict) + + if request.method == 'DELETE': + print("DELETE NOT YET IMPLEMENETED") + return jsonify(capture_metadata) + + else: # GET requests + return jsonify(capture_metadata) + + +@app.route(uri('/capture//download'), methods=['GET']) +def download_capture(capture_uuid): + """Return capture file by UUID""" + global microscope + + print(capture_uuid) + capture_obj = microscope.camera.image_from_id(capture_uuid) + + if not capture_obj: + return abort(404) # 404 Not Found + + as_attachment = get_bool(request.args.get('as_attachment')) + + return send_file( + capture_obj.data, + mimetype='image/jpeg', + as_attachment=as_attachment, + attachment_filename=capture_obj.filename) + +@app.route(uri('/capture//thumbnail'), methods=['GET']) +def thumb_capture(capture_uuid): + """Return capture thumbnail by UUID""" + global microscope + + print(capture_uuid) + capture_obj = microscope.camera.image_from_id(capture_uuid) + + if not capture_obj: + return abort(404) # 404 Not Found + + return send_file( + capture_obj.thumbnail, + mimetype='image/jpeg') + if __name__ == '__main__': app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False) diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 5770da31..34fb3339 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -4,6 +4,10 @@ import os import datetime import copy import logging +from PIL import Image + +pil_formats = ['JPG', 'JPEG', 'PNG', 'TIF', 'TIFF'] +thumbnail_size = (60, 60) class StreamObject(object): @@ -19,6 +23,9 @@ class StreamObject(object): self.id = uuid.uuid4().hex logging.info("Created {}".format(self.id)) + # Store file format + self.format = fmt + # Create file name iterator = 0 f_path, f_name = self.build_file_path(filename, folder, fmt) @@ -55,6 +62,9 @@ class StreamObject(object): # Object lock self.locked = False + # Thumbnail (populated only for JPEG captures) + self.thumb_bytes = None + def __enter__(self): """Create StreamObject in context, to auto-clean disk data.""" logging.debug("Entering context for {}.\ @@ -190,6 +200,25 @@ class StreamObject(object): """Return a byte string of the capture data.""" return self.data.getvalue() + @property + def thumbnail(self) -> io.BytesIO: + # If no thumbnail exists, try and make one + if not self.thumb_bytes: + print("Building thumbnail") + if self.format.upper() in pil_formats: + im = Image.open(self.data) + im.thumbnail(thumbnail_size) + + self.thumb_bytes = io.BytesIO() + im.save(self.thumb_bytes, self.format) + self.thumb_bytes.seek(0) + else: + self.thumb_bytes.seek(0) + + # Copy the buffer, to avoid closing the file + data = io.BytesIO(self.thumb_bytes.getbuffer()) + return data + def load_file(self) -> bool: """Load data stored on disk to the in-memory stream.""" if self.file_exists: # If data file exists