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 = `
+
+ `
+
+ // 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 @@
@@ -72,16 +72,25 @@
+
Capture
+ Filename:
+
+ Store on Pi:
+ Full resolution:
+ Resize capture:
+
+
+
+
Capture
+
Captures
-
-
Not yet implemented.
-
Capture 1
-
+
Update
+
-
+
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