Added capture and capture previews to web UI

This commit is contained in:
Joel Collins 2018-11-12 16:22:49 +00:00
parent 49331d36a8
commit d75efa630b
5 changed files with 252 additions and 30 deletions

View file

@ -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 = `
<div class="clearfix">
<div class="left-col"><img src="${element_uri}/thumbnail"></div>
<div class="right-col">
<b>${element.file}</b><br>
<a href="${element_uri}/download" target="_blank">View</a> <a href="${element_uri}/download?as_attachment=true">Download</a> Delete <a href="${element_uri}/" target="_blank">JSON</a>
<br>
</div>
</div>
`
// 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)
}
}

View file

@ -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;
}

View file

@ -28,14 +28,14 @@
<div class="clearfix">
<div class="left-col">FOV width:</div>
<div class="right-col">
<input type="text" id="fovXText" size="4" onchange="fovX = Number(this.value); updateTextBoxes();">
<input type="number" id="fovXText" onchange="fovX = Number(this.value); updateTextBoxes();" style="width: 7em" >
</div>
</div>
<div class="clearfix">
<div class="left-col">FOV height:</div>
<div class="right-col">
<input type="text" id="fovYText" size="4" onchange="fovY = Number(this.value); updateTextBoxes();">
<input type="number" id="fovYText" onchange="fovY = Number(this.value); updateTextBoxes();" style="width: 7em" >
</div>
</div>
</p>
@ -72,16 +72,25 @@
</div>
<div class="column right">
<h2>Capture</h2>
Filename: <br>
<input type="text" id="captureFilenameInput" placeholder="Leave blank for default"><br>
Store on Pi: <input type="checkbox" id="captureKeepOnDiskCheck"><br>
Full resolution: <input type="checkbox" id="captureFullResolutionCheck"><br>
Resize capture: <input type="checkbox" id="captureResizeCheck" onclick="newCaptureResizeToggle(this);"><br>
<input type="number" id="captureWidthInput" value="640" style="width: 7em" disabled>
<input type="number" id="captureHeightInput" value="480" style="width: 7em" disabled><br>
<button type="button" onclick="newCaptureFromInput();">Capture</button>
<h2>Captures</h2>
<div id="captures">
<p>Not yet implemented.</p>
<div class="capture">Capture 1</div>
</div>
<button type="button" onclick="getCaptures();">Update</button>
<div id="captures"></div>
</div>
<div class="column middle">
<img src="/api/v1/stream" ondblclick="clickHotspotImage(event)" ;="" style="width: 100%;">
<img src="/api/v1/stream" ondblclick="clickHotspotImage(event);">
</div>
</div>

View file

@ -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/<capture_uuid>/'), 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/<capture_uuid>/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/<capture_uuid>/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)

View file

@ -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