Compare commits

..

3 Commits

2 changed files with 372 additions and 195 deletions

View File

@ -1,12 +1,15 @@
import base64
import io
import json
import logging
import os
import sys
import threading
import time
import traceback
import ultimateAlprSdk
from PIL import Image
from PIL import Image, ImageDraw, ImageFont
from flask import Flask, request, jsonify, render_template
# Setup logging
@ -27,9 +30,9 @@ information. The server is created using Flask and the ultimateALPR SDK is used
See the README.md file for more information on how to run this script.
"""
# Load configuration from a JSON file or environment variables
# Load configuration
CONFIG_PATH = os.path.join(bundle_dir,
'config.json') # TODO: store config file outside of bundle (to avoid compilation by users)
'config.json') # TODO: store config file outside of bundle (to remove need for compilation by users)
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, 'r') as config_file:
JSON_CONFIG = json.load(config_file)
@ -84,11 +87,11 @@ def start_backend_loop():
load_engine()
# loop for about an hour or 3000 requests then reload the engine (fix for trial license)
while counter < 3000 and time.time() - boot_time < 60 * 60:
while counter < 3000 and time.time() - boot_time < 3600:
# every 120 sec
if int(time.time()) % 120 == 0:
if not is_engine_loaded():
unload_engine() # just in case
unload_engine()
load_engine()
time.sleep(1)
@ -123,23 +126,15 @@ def process_image(image: Image) -> str:
counter += 1
width, height = image.size
if image.mode in IMAGE_TYPES_MAPPING:
image_type = IMAGE_TYPES_MAPPING[image.mode]
else:
raise ValueError("Invalid mode: %s" % image.mode)
image_type = IMAGE_TYPES_MAPPING.get(image.mode, None)
if image_type is None:
raise ValueError(f"Invalid mode: {image.mode}")
result = ultimateAlprSdk.UltAlprSdkEngine_process(
image_type,
image.tobytes(),
width,
height,
0, # stride
1 # exifOrientation
image_type, image.tobytes(), width, height, 0, 1
)
if not result.isOK():
raise RuntimeError("Process failed: %s" % result.phrase())
else:
raise RuntimeError(f"Process failed: {result.phrase()}")
return result.json()
@ -149,7 +144,6 @@ def create_rest_server_flask():
@app.route('/v1/image/alpr', methods=['POST'])
def alpr():
"""
This function is called when a POST request is made to the /v1/image/alpr endpoint.
The function receives an image and processes it using the ultimateALPR SDK.
Parameters:
@ -159,6 +153,7 @@ def create_rest_server_flask():
"""
interference = time.time()
try:
if 'upload' not in request.files:
return jsonify({'error': 'No image found'}), 400
@ -169,24 +164,69 @@ def create_rest_server_flask():
else:
wanted_cells = list(range(1, grid_size * grid_size + 1))
image = request.files['upload']
if image.filename == '':
image_file = request.files['upload']
if image_file.filename == '':
return jsonify({'error': 'No selected file'}), 400
image = Image.open(image)
image = Image.open(image_file)
result = process_image(image)
result = convert_to_cpai_compatible(result)
if not result['predictions']:
logger.debug("No plate found in the image, attempting to split the image")
predictions_found = find_best_plate_with_split(image, grid_size, wanted_cells)
logger.debug("No plate found, attempting grid split")
predictions_found = find_best_plate_with_grid_split(image, grid_size, wanted_cells)
if predictions_found:
result['predictions'].append(max(predictions_found, key=lambda x: x['confidence']))
result['processMs'] = round((time.time() - interference) * 1000, 2)
result['inferenceMs'] = result['processMs']
if result['predictions']:
isolated_plate_image = isolate_plate_in_image(image, result['predictions'][0])
result['image'] = f"data:image/png;base64,{image_to_base64(isolated_plate_image, compress=True)}"
process_ms = round((time.time() - interference) * 1000, 2)
result.update({'processMs': process_ms, 'inferenceMs': process_ms})
return jsonify(result)
except Exception as e:
logger.error(f"Error processing image: {e}")
logger.error(traceback.format_exc())
return jsonify({'error': 'Error processing image'}), 500
@app.route('/v1/image/alpr_grid_debug', methods=['POST'])
def alpr_grid_debug():
"""
The function receives an image and returns it with the grid overlayed on it (for debugging purposes).
Parameters:
- upload: The image to be processed
- grid_size: The number of cells to split the image into (e.g. 4)
- wanted_cells: The cells to process in the grid separated by commas (e.g. 1,2,3,4) (max: grid_size²)
Returns:
- The image with the grid overlayed on it
"""
try:
if 'upload' not in request.files:
return jsonify({'error': 'No image found'}), 400
grid_size = int(request.form.get('grid_size', 3))
wanted_cells = request.form.get('wanted_cells')
if wanted_cells:
wanted_cells = [int(cell) for cell in wanted_cells.split(',')]
else:
wanted_cells = list(range(1, grid_size * grid_size + 1))
image_file = request.files['upload']
if image_file.filename == '':
return jsonify({'error': 'No selected file'}), 400
image = Image.open(image_file)
image = draw_grid_and_cell_numbers_on_image(image, grid_size, wanted_cells)
image_base64 = image_to_base64(image, compress=True)
return jsonify({"image": f"data:image/png;base64,{image_base64}"})
except Exception as e:
logger.error(f"Error processing image: {e}")
logger.error(traceback.format_exc())
return jsonify({'error': 'Error processing image'}), 500
@app.route('/')
def index():
@ -197,7 +237,6 @@ def create_rest_server_flask():
def convert_to_cpai_compatible(result):
result = json.loads(result)
response = {
'success': "true",
'processMs': result['duration'],
@ -215,36 +254,64 @@ def convert_to_cpai_compatible(result):
'timestamp': ''
}
if 'plates' in result:
plates = result['plates']
for plate in plates:
for plate in result.get('plates', []):
warpedBox = plate['warpedBox']
x_coords = warpedBox[0::2]
y_coords = warpedBox[1::2]
x_min = min(x_coords)
x_max = max(x_coords)
y_min = min(y_coords)
y_max = max(y_coords)
x_min, x_max = min(x_coords), max(x_coords)
y_min, y_max = min(y_coords), max(y_coords)
response['predictions'].append({
'confidence': plate['confidences'][0] / 100,
'label': "Plate: " + plate['text'],
'label': f"Plate: {plate['text']}",
'plate': plate['text'],
'x_min': x_min,
'x_max': x_max,
'y_min': y_min,
'y_max': y_max
})
return response
def find_best_plate_with_split(image: Image, grid_size: int = 3, wanted_cells: list = None):
def draw_grid_and_cell_numbers_on_image(image: Image, grid_size: int = 3, wanted_cells: list = None) -> Image:
if grid_size < 1:
grid_size = 1
if wanted_cells is None:
wanted_cells = list(range(1, grid_size * grid_size + 1))
width, height = image.size
cell_width = width // grid_size
cell_height = height // grid_size
draw = ImageDraw.Draw(image)
font = ImageFont.truetype(os.path.join(bundle_dir, 'assets', 'fonts', 'GlNummernschildEng-XgWd.ttf'),
image.size[0] // 10)
for cell_index in range(1, grid_size * grid_size + 1):
row = (cell_index - 1) // grid_size
col = (cell_index - 1) % grid_size
left = col * cell_width
upper = row * cell_height
right = left + cell_width
lower = upper + cell_height
if cell_index in wanted_cells:
draw.rectangle([left, upper, right, lower], outline="red", width=4)
draw.text((left + 5, upper + 5), str(cell_index), fill="red", font=font)
return image
def find_best_plate_with_grid_split(image: Image, grid_size: int = 3, wanted_cells: list = None):
if grid_size < 1:
logger.debug("Grid size < 1, skipping split")
return []
if wanted_cells is None:
wanted_cells = list(range(1, grid_size * grid_size + 1))
predictions_found = []
width, height = image.size
cell_width = width // grid_size
cell_height = height // grid_size
@ -261,8 +328,7 @@ def find_best_plate_with_split(image: Image, grid_size: int = 3, wanted_cells: l
cell_image = image.crop((left, upper, right, lower))
result_cell = json.loads(process_image(cell_image))
if 'plates' in result_cell:
for plate in result_cell['plates']:
for plate in result_cell.get('plates', []):
warpedBox = plate['warpedBox']
x_coords = warpedBox[0::2]
y_coords = warpedBox[1::2]
@ -273,7 +339,7 @@ def find_best_plate_with_split(image: Image, grid_size: int = 3, wanted_cells: l
predictions_found.append({
'confidence': plate['confidences'][0] / 100,
'label': "Plate: " + plate['text'],
'label': f"Plate: {plate['text']}",
'plate': plate['text'],
'x_min': x_min,
'x_max': x_max,
@ -284,6 +350,32 @@ def find_best_plate_with_split(image: Image, grid_size: int = 3, wanted_cells: l
return predictions_found
def isolate_plate_in_image(image: Image, plate: dict) -> Image:
x_min, x_max = plate['x_min'], plate['x_max']
y_min, y_max = plate['y_min'], plate['y_max']
offset = 10
cropped_image = image.crop((max(0, x_min - offset), max(0, y_min - offset), min(image.size[0], x_max + offset),
min(image.size[1], y_max + offset)))
resized_image = cropped_image.resize((int(cropped_image.size[0] * 3), int(cropped_image.size[1] * 3)),
resample=Image.Resampling.LANCZOS)
return resized_image
def image_to_base64(img: Image, compress=False):
"""Convert a Pillow image to a base64-encoded string."""
buffered = io.BytesIO()
if compress:
img = img.resize((img.size[0] // 2, img.size[1] // 2))
img.save(buffered, format="WEBP", quality=35, lossless=False)
else:
img.save(buffered, format="WEBP")
return base64.b64encode(buffered.getvalue()).decode('utf-8')
if __name__ == '__main__':
engine_thread = threading.Thread(target=start_backend_loop, daemon=True)
engine_thread.start()

View File

@ -1,11 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Easy Local ALPR - API</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- Include Google Sans font -->
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body {
@ -14,144 +14,229 @@
background-size: 20px 20px;
font-family: 'Google Sans', sans-serif;
}
.loading-circle {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #000;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
input[type="text"], input[type="number"] {
background-color: white;
color: black;
}
input[type="text"].dark, input[type="number"].dark {
background-color: #3b3b3b;
color: white;
}
</style>
</head>
<body class="bg-neutral-100 dark:bg-neutral-900 dark:text-white flex items-center justify-center min-h-screen p-4">
<!-- Logo -->
<div class="absolute top-4 left-4 z-50">
<img id="logo" src="{{ url_for('static', filename='logo_black.webp') }}" alt="Logo" class="h-12 dark:hidden">
<img id="logoDark" src="{{ url_for('static', filename='logo_white.webp') }}" alt="Logo" class="h-12 hidden dark:block">
<div class="absolute top-4 left-4 z-50">
<img alt="Logo" class="h-12 dark:hidden" id="logo" src="{{ url_for('static', filename='logo_black.webp') }}">
<img alt="Logo" class="h-12 hidden dark:block" id="logoDark"
src="{{ url_for('static', filename='logo_white.webp') }}">
</div>
<div class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-lg w-full max-w-xl mt-16">
<h1 class="text-2xl font-bold mb-4 text-center dark:text-gray-200">Select Service</h1>
<form class="space-y-4" id="serviceForm">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="service">Choose a
service:</label>
<select class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white dark:bg-neutral-800 dark:border-neutral-700 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
id="service" name="service" onchange="updateFormFields()">
<option value="alpr">Plate Recognition (ALPR)</option>
<option value="alpr_grid_debug">Grid Size Helper</option>
</select>
</div>
<div class="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-lg w-full max-w-md mt-16">
<h1 class="text-2xl font-bold mb-4 text-center dark:text-gray-200">Upload Image for ALPR</h1>
<form id="uploadForm" enctype="multipart/form-data" class="space-y-4">
<div class="service-fields hidden" id="alprFields">
<div>
<label for="upload" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Choose an image:</label>
<label for="upload_alpr" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Choose an
image:</label>
<div class="mt-1 flex items-center">
<input type="file" id="upload" name="upload" accept="image/*" class="hidden" onchange="updateFileName()">
<label for="upload" class="cursor-pointer inline-flex items-center justify-center px-4 py-2 border border-gray-400 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-600">
Select file
</label>
<span id="fileName" class="ml-2 text-sm text-gray-600 dark:text-gray-300"></span>
<input type="file" id="upload_alpr" name="upload" accept="image/*" class="hidden"
onchange="updateFileName();">
<label for="upload_alpr"
class="cursor-pointer inline-flex items-center justify-center px-4 py-2 border border-gray-400 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-600">Select
file</label>
<span id="fileName_alpr" class="ml-2 text-sm text-gray-600 dark:text-gray-300"></span>
</div>
</div>
<div>
<label for="grid_size" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Grid Size:</label>
<input type="number" id="grid_size" name="grid_size" value="3" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark">
<label for="grid_size_alpr" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Grid
Size:</label>
<input type="number" id="grid_size_alpr" name="grid_size" value="3"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:bg-neutral-800 dark:border-neutral-700">
</div>
<div>
<label for="wanted_cells" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Wanted Cells:</label>
<input type="text" id="wanted_cells" name="wanted_cells" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark">
<label for="wanted_cells_alpr" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Wanted
Cells:</label>
<input type="text" id="wanted_cells_alpr" name="wanted_cells"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:bg-neutral-800 dark:border-neutral-700">
</div>
<input id="plate_image_alpr" name="plate_image_alpr" type="hidden" value="true">
</div>
<div class="service-fields hidden" id="alpr_grid_debugFields">
<div>
<label for="upload_alpr_grid_debug" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Choose
an image:</label>
<div class="mt-1 flex items-center">
<input type="file" id="upload_alpr_grid_debug" name="upload" accept="image/*" class="hidden"
onchange="updateFileName();">
<label for="upload_alpr_grid_debug"
class="cursor-pointer inline-flex items-center justify-center px-4 py-2 border border-gray-400 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-600">Select
file</label>
<span id="fileName_alpr_grid_debug" class="ml-2 text-sm text-gray-600 dark:text-gray-300"></span>
</div>
</div>
<div>
<label for="grid_size_alpr_grid_debug"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Grid Size:</label>
<input type="number" id="grid_size_alpr_grid_debug" name="grid_size" value="3"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:bg-neutral-800 dark:border-neutral-700">
</div>
<div>
<label for="wanted_cells_alpr_grid_debug"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Wanted Cells:</label>
<input type="text" id="wanted_cells_alpr_grid_debug" name="wanted_cells"
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:bg-neutral-800 dark:border-neutral-700">
</div>
</div>
<div id="imagePreview" class="mt-4 hidden">
<label id="imagePreviewLabel"
class="block text sm font-medium text-gray-700 dark:text-gray-300">Preview:</label>
<img id="previewImage" src="#" alt="Preview" class="max-w-full h-auto rounded-lg">
</div>
<button id="submitButton" type="submit" class="w-full py-2 px-4 bg-black text-white font-semibold rounded-md shadow-sm hover:bg-neutral-900 dark:bg-neutral-900 dark:hover:bg-neutral-950 relative flex justify-center items-center">
<span>Upload</span>
<div id="loadingCircle" class="loading-circle absolute hidden"></div>
<button class="w-full py-2 px-4 bg-black text-white font-semibold rounded-md shadow-sm hover:bg-neutral-900 dark:bg-neutral-900 dark:hover:bg-neutral-950"
id="submitButton" type="submit">Submit
</button>
</form>
<div class="mt-6">
<h2 class="text-xl font-semibold mb-2 dark:text-gray-200">Response</h2>
<pre id="responseBox" class="bg-neutral-100 dark:bg-neutral-900 p-4 border rounded-lg text-sm text-gray-900 dark:text-gray-200 overflow-x-auto"></pre>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script>
<div class="mt-6">
<h2 class="text-xl font-semibold mb-2 dark:text-gray-200">
Response
<span class="text-sm font-normal" id="timer"></span>
</h2>
<pre class="bg-neutral-100 dark:bg-neutral-900 p-4 border rounded-lg text-sm text-gray-900 dark:text-gray-200 overflow-x-auto"
id="responseBox"></pre>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script>
function updateFormFields() {
const service = document.getElementById('service').value;
localStorage.setItem('selectedService', service);
document.querySelectorAll('.service-fields').forEach(field => {
field.classList.add('hidden');
field.querySelectorAll('input, select').forEach(field => field.disabled = true);
});
const selectedServiceFields = document.getElementById(service + 'Fields');
selectedServiceFields.classList.remove('hidden');
selectedServiceFields.querySelectorAll('input, select').forEach(field => field.disabled = false);
['responseBox', 'timer', 'fileName_' + service, 'previewImage', 'imagePreview', 'upload_' + service]
.forEach(id => {
const element = document.getElementById(id);
if (element) {
if (element.tagName === 'DIV') element.classList.add('hidden');
if (element.tagName === 'INPUT') element.value = '';
if (element.tagName === 'SPAN' || element.tagName === 'PRE') element.textContent = '';
if (element.tagName === 'IMG') element.src = '';
}
});
}
function initializeForm() {
const savedService = localStorage.getItem('selectedService');
if (savedService) {
document.getElementById('service').value = savedService;
updateFormFields();
}
}
function toggleLogo() {
const logo = document.getElementById('logo');
const logoDark = document.getElementById('logoDark');
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
logo.style.display = 'none';
logoDark.style.display = 'block';
} else {
logo.style.display = 'block';
logoDark.style.display = 'none';
}
}
function updateFileName() {
var input = document.getElementById('upload');
var fileName = document.getElementById('fileName');
var imagePreview = document.getElementById('imagePreview');
var previewImage = document.getElementById('previewImage');
const service = document.getElementById('service').value;
const input = document.getElementById('upload_' + service);
const fileName = document.getElementById('fileName_' + service);
const imagePreview = document.getElementById('imagePreview');
const previewImage = document.getElementById('previewImage');
const imagePreviewLabel = document.getElementById('imagePreviewLabel');
fileName.textContent = input.files[0] ? input.files[0].name : '';
imagePreviewLabel.textContent = 'Preview:';
if (input.files && input.files[0]) {
var reader = new FileReader();
reader.onload = function (e) {
const reader = new FileReader();
reader.onload = (e) => {
previewImage.src = e.target.result;
imagePreview.classList.remove('hidden');
}
reader.readAsDataURL(input.files[0]);
}
}
const prefersDarkScheme = window.matchMedia("(prefers-color-scheme: dark)");
function toggleLogo() {
const logo = document.getElementById('logo');
const logoDark = document.getElementById('logoDark');
const upload = document.getElementById('upload');
const gridSize = document.getElementById('grid_size');
const wantedCells = document.getElementById('wanted_cells');
if (prefersDarkScheme.matches) {
logo.style.display = 'none';
logoDark.style.display = 'block';
upload.classList.add('dark');
gridSize.classList.add('dark');
wantedCells.classList.add('dark');
} else {
logo.style.display = 'block';
logoDark.style.display = 'none';
upload.classList.remove('dark');
gridSize.classList.remove('dark');
wantedCells.classList.remove('dark');
}
}
toggleLogo();
prefersDarkScheme.addEventListener('change', toggleLogo);
$(document).ready(function () {
$('#uploadForm').on('submit', function (e) {
initializeForm();
toggleLogo();
window.matchMedia("(prefers-color-scheme: dark)").addEventListener('change', toggleLogo);
$('#serviceForm').on('submit', function (e) {
e.preventDefault();
var formData = new FormData(this);
$('#submitButton').prop('disabled', true);
$('#loadingCircle').removeClass('hidden');
const service = $('#service').val();
const formData = new FormData(this);
var url;
if (service === 'alpr') {
url = '/v1/image/alpr';
type = 'POST';
} else if (service === 'alpr_grid_debug') {
url = '/v1/image/alpr_grid_debug';
type = 'POST';
}
$('#submitButton').prop('disabled', true).html('<svg class="animate-spin h-5 w-5 text-white mx-auto" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M12 2a10 10 0 00-8 4.9l1.5 1A8 8 0 0112 4V2z"></path></svg>');
const startTime = Date.now();
$.ajax({
url: '/v1/image/alpr',
type: 'POST',
url: url,
type: type,
data: formData,
processData: false,
contentType: false,
success: function (data) {
const endTime = Date.now();
const elapsedTime = endTime - startTime;
$('#responseBox').text(JSON.stringify(data, null, 2));
$('#timer').text(`(${elapsedTime} ms)`);
$('#submitButton').prop('disabled', false).text('Submit');
if (data.image) {
$('#previewImage').attr('src', data.image);
$('#imagePreview').removeClass('hidden');
if (service === 'alpr') $('#imagePreviewLabel').text('Identified plate image:');
} else {
updateFileName();
}
},
error: function (xhr, status, error) {
var err = JSON.parse(xhr.responseText);
error: function (xhr) {
const endTime = Date.now();
const elapsedTime = endTime - startTime;
const err = JSON.parse(xhr.responseText);
$('#responseBox').text(JSON.stringify(err, null, 2));
},
complete: function () {
$('#submitButton').prop('disabled', false);
$('#loadingCircle').addClass('hidden');
$('#timer').text(`(${elapsedTime} ms)`);
$('#submitButton').prop('disabled', false).text('Submit');
}
});
});
});
</script>
</script>
</body>
</html>