Added capture and capture previews to web UI
This commit is contained in:
parent
49331d36a8
commit
d75efa630b
5 changed files with 252 additions and 30 deletions
|
|
@ -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"
|
var baseURI = "/api/v1"
|
||||||
|
|
||||||
|
|
@ -42,7 +54,36 @@ function getCaptures() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCaptures(response) {
|
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() {
|
function updateTextBoxes() {
|
||||||
|
|
@ -56,9 +97,9 @@ function updateTextBoxes() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStagePositions(response) {
|
function updateStagePositions(response) {
|
||||||
document.getElementById('x_abs').value = response["x"];
|
document.getElementById('x_abs').value = response.x;
|
||||||
document.getElementById('y_abs').value = response["y"];
|
document.getElementById('y_abs').value = response.y;
|
||||||
document.getElementById('z_abs').value = response["z"];
|
document.getElementById('z_abs').value = response.z;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStagePositions() {
|
function getStagePositions() {
|
||||||
|
|
@ -69,13 +110,79 @@ function getStagePositions() {
|
||||||
safeRequest("GET", baseURI+"/position", null, updatePositionCallback, false)
|
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'
|
// TODO: Combine setStagePositions and moveStagePositions, with bool argument for 'absolute'
|
||||||
|
|
||||||
function setStagePositions(x_abs, y_abs, z_abs) {
|
function setStagePositions(x_abs, y_abs, z_abs) {
|
||||||
// Make a position request
|
// Make a position request
|
||||||
function absMoveCallback(response, status) {
|
function absMoveCallback(response, status) {
|
||||||
if (status == 400) {
|
if (status != 200) {
|
||||||
alert("Stage cannot be moved further than the safe range.");
|
alert("Error moving stage.");
|
||||||
}
|
}
|
||||||
console.log(status, response);
|
console.log(status, response);
|
||||||
updateStagePositions(response);
|
updateStagePositions(response);
|
||||||
|
|
@ -86,8 +193,8 @@ function setStagePositions(x_abs, y_abs, z_abs) {
|
||||||
function moveStagePositions(x_rel, y_rel, z_rel) {
|
function moveStagePositions(x_rel, y_rel, z_rel) {
|
||||||
// Make a position request
|
// Make a position request
|
||||||
function relMoveCallback(response, status) {
|
function relMoveCallback(response, status) {
|
||||||
if (status == 400) {
|
if (status != 200) {
|
||||||
alert("Stage cannot be moved further than the safe range.");
|
alert("Error moving stage.");
|
||||||
}
|
}
|
||||||
console.log(status, response);
|
console.log(status, response);
|
||||||
updateStagePositions(response);
|
updateStagePositions(response);
|
||||||
|
|
@ -131,9 +238,9 @@ function clickHotspotImage(event) {
|
||||||
window.addEventListener('wheel', function(e) {
|
window.addEventListener('wheel', function(e) {
|
||||||
multiplier = 1/100;
|
multiplier = 1/100;
|
||||||
z_delta = e.deltaY * multiplier * focusVelocity;
|
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(z_delta);
|
||||||
console.log(e.target.parentNode.classList["value"]);
|
console.log(e.target.parentNode.classList.value);
|
||||||
// Make a position request
|
// Make a position request
|
||||||
safeRequest("POST", baseURI+"/position", { "absolute": false, "z": z_delta}, keyMoveCallback)
|
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
|
var xhr = new XMLHttpRequest(); // new HttpRequest instance
|
||||||
xhr.open(method, url);
|
xhr.open(method, url);
|
||||||
|
|
||||||
if (lock == true) {
|
if (lock == true) { // If request is using the lock
|
||||||
requestLock = true;
|
requestLock = true; // Hold the lock
|
||||||
}
|
}
|
||||||
|
|
||||||
xhr.onload = function (e) {
|
xhr.onload = function (e) {
|
||||||
if (xhr.readyState === 4) {
|
if (xhr.readyState === 4) {
|
||||||
|
if (lock == true) { // If request is using the lock
|
||||||
|
requestLock = false; // Release the lock
|
||||||
|
}
|
||||||
|
|
||||||
if (xhr.status !== 200) {
|
if (xhr.status !== 200) {
|
||||||
console.error(xhr.statusText);
|
console.error(xhr.statusText);
|
||||||
}
|
}
|
||||||
callback(JSON.parse(xhr.responseText), xhr.status);
|
callback(JSON.parse(xhr.responseText), xhr.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lock == true) {
|
|
||||||
requestLock = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (method == "POST" || method == "PUT") {
|
if (method == "POST" || method == "PUT") {
|
||||||
|
|
@ -221,6 +328,7 @@ function safeRequest(method, url, payload, callback, lock=true, force=false) {
|
||||||
|
|
||||||
}
|
}
|
||||||
else {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Preview sizing */
|
/* Preview sizing */
|
||||||
img {
|
.middle img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain
|
object-fit: contain
|
||||||
|
|
@ -68,8 +68,14 @@ img {
|
||||||
|
|
||||||
#captures {
|
#captures {
|
||||||
font-size:12px;
|
font-size:12px;
|
||||||
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture {
|
.capture {
|
||||||
background-color: #d3d3d3;
|
background-color: #d3d3d3;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture img {
|
||||||
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
@ -28,14 +28,14 @@
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="left-col">FOV width:</div>
|
<div class="left-col">FOV width:</div>
|
||||||
<div class="right-col">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="left-col">FOV height:</div>
|
<div class="left-col">FOV height:</div>
|
||||||
<div class="right-col">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -72,16 +72,25 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column right">
|
<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>
|
<h2>Captures</h2>
|
||||||
<div id="captures">
|
<button type="button" onclick="getCaptures();">Update</button>
|
||||||
<p>Not yet implemented.</p>
|
<div id="captures"></div>
|
||||||
<div class="capture">Capture 1</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column middle">
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import datetime
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask, render_template, Response,
|
Flask, render_template, Response,
|
||||||
redirect, request, jsonify, send_file)
|
redirect, request, jsonify, send_file, abort)
|
||||||
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -119,8 +119,10 @@ def position():
|
||||||
|
|
||||||
return jsonify(microscope.state['position'])
|
return jsonify(microscope.state['position'])
|
||||||
|
|
||||||
|
|
||||||
# Capture routes
|
# Capture routes
|
||||||
@app.route(uri('/capture'), methods=['GET', 'POST', 'PUT'])
|
|
||||||
|
@app.route(uri('/capture/'), methods=['GET', 'POST', 'PUT'])
|
||||||
def capture():
|
def capture():
|
||||||
"""Return JSONified microscope state"""
|
"""Return JSONified microscope state"""
|
||||||
global microscope
|
global microscope
|
||||||
|
|
@ -173,5 +175,73 @@ def capture():
|
||||||
|
|
||||||
return jsonify(metadata_array)
|
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__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False)
|
app.run(host='0.0.0.0', threaded=True, debug=True, use_reloader=False)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import os
|
||||||
import datetime
|
import datetime
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
pil_formats = ['JPG', 'JPEG', 'PNG', 'TIF', 'TIFF']
|
||||||
|
thumbnail_size = (60, 60)
|
||||||
|
|
||||||
|
|
||||||
class StreamObject(object):
|
class StreamObject(object):
|
||||||
|
|
@ -19,6 +23,9 @@ class StreamObject(object):
|
||||||
self.id = uuid.uuid4().hex
|
self.id = uuid.uuid4().hex
|
||||||
logging.info("Created {}".format(self.id))
|
logging.info("Created {}".format(self.id))
|
||||||
|
|
||||||
|
# Store file format
|
||||||
|
self.format = fmt
|
||||||
|
|
||||||
# Create file name
|
# Create file name
|
||||||
iterator = 0
|
iterator = 0
|
||||||
f_path, f_name = self.build_file_path(filename, folder, fmt)
|
f_path, f_name = self.build_file_path(filename, folder, fmt)
|
||||||
|
|
@ -55,6 +62,9 @@ class StreamObject(object):
|
||||||
# Object lock
|
# Object lock
|
||||||
self.locked = False
|
self.locked = False
|
||||||
|
|
||||||
|
# Thumbnail (populated only for JPEG captures)
|
||||||
|
self.thumb_bytes = None
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Create StreamObject in context, to auto-clean disk data."""
|
"""Create StreamObject in context, to auto-clean disk data."""
|
||||||
logging.debug("Entering context for {}.\
|
logging.debug("Entering context for {}.\
|
||||||
|
|
@ -190,6 +200,25 @@ class StreamObject(object):
|
||||||
"""Return a byte string of the capture data."""
|
"""Return a byte string of the capture data."""
|
||||||
return self.data.getvalue()
|
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:
|
def load_file(self) -> bool:
|
||||||
"""Load data stored on disk to the in-memory stream."""
|
"""Load data stored on disk to the in-memory stream."""
|
||||||
if self.file_exists: # If data file exists
|
if self.file_exists: # If data file exists
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue