|
@@ -0,0 +1,359 @@
|
|
|
+<!-- This is a Jinja2 Template that extends layout.html
|
|
|
+Implements a drag & drop file upload interface for YOLOv5 detection.
|
|
|
+
|
|
|
+Once a user image is uploaded, this code AJAX requests the image, model name
|
|
|
+and image size to the FastAPI server's /detect endpoint. The server sends
|
|
|
+the list of bounding boxes back, and the image + bounding boxes + labels
|
|
|
+are rendered inside the canvas element.
|
|
|
+
|
|
|
+Bounding box labels' height above the bounding box are adjusted to not overlap
|
|
|
+with eachother in complicated scenes (such as a busy city street).
|
|
|
+
|
|
|
+TODO:
|
|
|
+ - Improve efficiency of the algorithm for making box labels not overlap in crowded scenes
|
|
|
+-->
|
|
|
+
|
|
|
+{% extends "layout.html" %} {% block title %}
|
|
|
+<title>YOLOv5 Drag & Drop Demo</title> {% endblock %} {% block header %}
|
|
|
+<!-- Include JQuery for this page -->
|
|
|
+<script
|
|
|
+ src="https://code.jquery.com/jquery-3.6.0.min.js"
|
|
|
+ integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
|
|
+ crossorigin="anonymous"
|
|
|
+></script>
|
|
|
+{% endblock %} {% block content %}
|
|
|
+<div style="overflow-x: hidden">
|
|
|
+ <div class="row">
|
|
|
+ <div class="col-auto m-2">
|
|
|
+ <label for="model_name" class="form-label"
|
|
|
+ ><b>Select YOLOv5 Model</b></label
|
|
|
+ >
|
|
|
+ <select class="form-select" id="model_name" name="model_name">
|
|
|
+ {% for selection in model_selection_options %}
|
|
|
+ <option value="{{ selection }}">{{ selection }}</option>
|
|
|
+ {% endfor %}
|
|
|
+ </select>
|
|
|
+ <label for="img_size" class="form-label"
|
|
|
+ ><b>Model Inference Size</b></label
|
|
|
+ >
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ class="form-control"
|
|
|
+ id="img_size"
|
|
|
+ name="img_size"
|
|
|
+ value="1824"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="col">
|
|
|
+ <!-- Drag and drop images in this div -->
|
|
|
+ <div
|
|
|
+ class="m-2"
|
|
|
+ id="drop-region"
|
|
|
+ style="border: 3px dashed limegreen; height: 150px"
|
|
|
+ >
|
|
|
+ <div class="container-fluid">
|
|
|
+ <div class="d-flex justify-content-center align-items-center h-100">
|
|
|
+ <b>Drag & Drop Images Or Click To Upload</b>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Our image + bboxes will be drawn here! Downscale image to fit page width-->
|
|
|
+ <canvas id="canvas" style="max-width: 99%; height: auto"></canvas>
|
|
|
+</div>
|
|
|
+
|
|
|
+<script type="text/javascript">
|
|
|
+ /*This script block handles canvas updates after getting bboxes from the server. */
|
|
|
+
|
|
|
+ const canvas = document.getElementById('canvas')
|
|
|
+ const ctx = canvas.getContext('2d')
|
|
|
+ var image = new Image(60, 45) // Using optional size for image
|
|
|
+ image.onload = drawImageWithBBoxes // Draw when image has loaded
|
|
|
+
|
|
|
+ var colormap = {
|
|
|
+ car: [255, 216, 0], //yellow
|
|
|
+ person: [255, 0, 0], //red
|
|
|
+ truck: [255, 0, 255], //purple
|
|
|
+ }
|
|
|
+
|
|
|
+ var data //list of list of dictionaries obtained from server's /detect endpoint
|
|
|
+
|
|
|
+ function drawImageWithBBoxes() {
|
|
|
+ // Use the intrinsic size of image in CSS pixels for the canvas element
|
|
|
+ canvas.width = this.naturalWidth
|
|
|
+ canvas.height = this.naturalHeight
|
|
|
+
|
|
|
+ //draw the image
|
|
|
+ ctx.drawImage(this, 0, 0)
|
|
|
+
|
|
|
+ //draw some bboxes!
|
|
|
+ ctx.lineWidth = 2
|
|
|
+ ctx.font = '24px arial'
|
|
|
+ ctx.strokeStyle = 'yellow'
|
|
|
+ textboxlocations = []
|
|
|
+ FILL_ALPHA = 1
|
|
|
+
|
|
|
+ const randomBetween = (min, max) =>
|
|
|
+ min + Math.floor(Math.random() * (max - min + 1))
|
|
|
+ for (const item of data) {
|
|
|
+ if (item['class_name'] in colormap) {
|
|
|
+ rgb = colormap[item['class_name']]
|
|
|
+ } else {
|
|
|
+ //random color for this class
|
|
|
+ rgb = [
|
|
|
+ randomBetween(0, 255),
|
|
|
+ randomBetween(0, 255),
|
|
|
+ randomBetween(0, 255),
|
|
|
+ ]
|
|
|
+ colormap[item['class_name']] = rgb
|
|
|
+ }
|
|
|
+ ctx.fillStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${FILL_ALPHA})`
|
|
|
+ ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${FILL_ALPHA})`
|
|
|
+
|
|
|
+ // draw bbox
|
|
|
+ ctx.strokeRect(
|
|
|
+ item['bbox'][0],
|
|
|
+ item['bbox'][1],
|
|
|
+ item['bbox'][2] - item['bbox'][0],
|
|
|
+ item['bbox'][3] - item['bbox'][1]
|
|
|
+ )
|
|
|
+
|
|
|
+ let label = `${item['class_name']} ${do_rounding(item['confidence'])}`
|
|
|
+ let textMeasures = ctx.measureText(label)
|
|
|
+ let textHeight =
|
|
|
+ textMeasures.actualBoundingBoxAscent +
|
|
|
+ textMeasures.actualBoundingBoxDescent
|
|
|
+ let padding = 2
|
|
|
+
|
|
|
+ let x = item['bbox'][0]
|
|
|
+ let y = item['bbox'][1] - textHeight - 2 * padding
|
|
|
+ let w = textMeasures.width + 2 * padding
|
|
|
+ let h = textHeight + 2 * padding
|
|
|
+
|
|
|
+ //check if new textbox would overlap with previous textboxes drawn
|
|
|
+ while (
|
|
|
+ textboxlocations.some(box => IOU(box, [x, y, x + w, y + h]) > 0.01)
|
|
|
+ ) {
|
|
|
+ //if so, move the textbox up by h + 5 pixels
|
|
|
+ y -= h + 3
|
|
|
+ }
|
|
|
+ //don't let the textbox go beyond top of the image
|
|
|
+ if (y <= 0) {
|
|
|
+ y = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ textboxlocations.push([x - 1, y - 1, x + w + 1, y + h + 1])
|
|
|
+ // draw text background box
|
|
|
+ ctx.fillRect(x, y, w, h)
|
|
|
+
|
|
|
+ //draw text
|
|
|
+ if (rgb[0] + rgb[1] + rgb[2] > (255 * 3) / 2) {
|
|
|
+ ctx.fillStyle = 'black'
|
|
|
+ } else {
|
|
|
+ ctx.fillStyle = 'white'
|
|
|
+ }
|
|
|
+ ctx.fillText(label, x + padding, y + h - padding)
|
|
|
+
|
|
|
+ //draw line between text and bbox top left corner
|
|
|
+
|
|
|
+ ctx.beginPath()
|
|
|
+ ctx.moveTo(x, y)
|
|
|
+ ctx.lineTo(x, item['bbox'][1])
|
|
|
+ ctx.stroke()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function IOU(boxA, boxB, isPixel = 0) {
|
|
|
+ /*This function computes the IOU of 2 boxes.
|
|
|
+ This is used solely to make sure bbox labels don't overlap vertically */
|
|
|
+
|
|
|
+ // determine the (x, y)-coordinates of the intersection rectangle
|
|
|
+ xA = Math.max(boxA[0], boxB[0])
|
|
|
+ yA = Math.max(boxA[1], boxB[1])
|
|
|
+ xB = Math.min(boxA[2], boxB[2])
|
|
|
+ yB = Math.min(boxA[3], boxB[3])
|
|
|
+
|
|
|
+ if (xA >= xB || yA >= yB) {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+
|
|
|
+ //compute the area of intersection rectangle
|
|
|
+ interArea = (xB - xA + isPixel) * (yB - yA + isPixel)
|
|
|
+
|
|
|
+ //compute the area of both rectangles
|
|
|
+ boxAArea = (boxA[2] - boxA[0] + isPixel) * (boxA[3] - boxA[1] + isPixel)
|
|
|
+ boxBArea = (boxB[2] - boxB[0] + isPixel) * (boxB[3] - boxB[1] + isPixel)
|
|
|
+
|
|
|
+ // compute the intersection over union by taking the intersection
|
|
|
+ // area and dividing it by the sum of areas - the interesection area
|
|
|
+ iou = interArea / (boxAArea + boxBArea - interArea)
|
|
|
+ return iou
|
|
|
+ }
|
|
|
+
|
|
|
+ function do_rounding(num, places = 2) {
|
|
|
+ return Math.round((num + Number.EPSILON) * 10 ** places) / 10 ** places
|
|
|
+ }
|
|
|
+</script>
|
|
|
+
|
|
|
+<script type="text/javascript">
|
|
|
+ /*This script block handles the drag and drop + AJAX request to server */
|
|
|
+
|
|
|
+ // where files are dropped + file selector is opened
|
|
|
+ var dropRegion = document.getElementById('drop-region')
|
|
|
+
|
|
|
+ // open file selector when clicked on the drop region
|
|
|
+ var fakeInput = document.createElement('input')
|
|
|
+ fakeInput.type = 'file'
|
|
|
+ fakeInput.accept = 'image/*'
|
|
|
+ fakeInput.multiple = false //dont allow multiple file upload
|
|
|
+ dropRegion.addEventListener('click', function () {
|
|
|
+ fakeInput.click()
|
|
|
+ })
|
|
|
+
|
|
|
+ function validateImage(image) {
|
|
|
+ // check the type
|
|
|
+ var validTypes = ['image/jpeg', 'image/png', 'image/gif']
|
|
|
+ if (validTypes.indexOf(image.type) === -1) {
|
|
|
+ alert('Invalid File Type')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ // check the size
|
|
|
+ var maxSizeInBytes = 10e6 // 10MB
|
|
|
+ if (image.size > maxSizeInBytes) {
|
|
|
+ alert('File too large')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ return true
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleFiles(files) {
|
|
|
+ for (var i = 0, len = files.length; i < len; i++) {
|
|
|
+ if (validateImage(files[i])) previewAnduploadImage(files[i])
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ fakeInput.addEventListener('change', function () {
|
|
|
+ var files = fakeInput.files
|
|
|
+ handleFiles(files)
|
|
|
+ })
|
|
|
+ function preventDefault(e) {
|
|
|
+ e.preventDefault()
|
|
|
+ e.stopPropagation()
|
|
|
+ }
|
|
|
+
|
|
|
+ dropRegion.addEventListener('dragenter', preventDefault, false)
|
|
|
+ dropRegion.addEventListener('dragleave', preventDefault, false)
|
|
|
+ dropRegion.addEventListener('dragover', preventDefault, false)
|
|
|
+ dropRegion.addEventListener('drop', preventDefault, false)
|
|
|
+
|
|
|
+ function handleDrop(e) {
|
|
|
+ var data = e.dataTransfer,
|
|
|
+ files = data.files
|
|
|
+
|
|
|
+ handleFiles(files)
|
|
|
+ }
|
|
|
+
|
|
|
+ dropRegion.addEventListener('drop', handleDrop, false)
|
|
|
+
|
|
|
+ function handleDrop(e) {
|
|
|
+ var data = e.dataTransfer,
|
|
|
+ files = data.files
|
|
|
+
|
|
|
+ handleFiles(files)
|
|
|
+ }
|
|
|
+
|
|
|
+ dropRegion.addEventListener('drop', handleDrop, false)
|
|
|
+
|
|
|
+ function handleDrop(e) {
|
|
|
+ var dt = e.dataTransfer,
|
|
|
+ files = dt.files
|
|
|
+
|
|
|
+ if (files.length) {
|
|
|
+ handleFiles(files)
|
|
|
+ } else {
|
|
|
+ // check for img
|
|
|
+ var html = dt.getData('text/html'),
|
|
|
+ match = html && /\bsrc="?([^"\s]+)"?\s*/.exec(html),
|
|
|
+ url = match && match[1]
|
|
|
+
|
|
|
+ if (url) {
|
|
|
+ uploadImageFromURL(url)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function uploadImageFromURL(url) {
|
|
|
+ var img = new Image()
|
|
|
+ var c = document.createElement('canvas')
|
|
|
+ var ctx = c.getContext('2d')
|
|
|
+
|
|
|
+ img.onload = function () {
|
|
|
+ c.width = this.naturalWidth // update canvas size to match image
|
|
|
+ c.height = this.naturalHeight
|
|
|
+ ctx.drawImage(this, 0, 0) // draw in image
|
|
|
+ c.toBlob(function (blob) {
|
|
|
+ // get content as PNG blob
|
|
|
+
|
|
|
+ // call our main function
|
|
|
+ handleFiles([blob])
|
|
|
+ }, 'image/png')
|
|
|
+ }
|
|
|
+ img.onerror = function () {
|
|
|
+ alert('Error in uploading')
|
|
|
+ }
|
|
|
+ img.crossOrigin = '' // if from different origin
|
|
|
+ img.src = url
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function previewAnduploadImage(img) {
|
|
|
+ /* This function reads the user's image, AJAX requests it to the server,
|
|
|
+ JSON parses the result and draws the image onto the canvas.
|
|
|
+
|
|
|
+ The bboxes are drawn in drawImageWithBBoxes() which get run after
|
|
|
+ image is drawn on the canvas. */
|
|
|
+
|
|
|
+ // read the image...
|
|
|
+ var reader = new FileReader()
|
|
|
+ reader.onload = function (e) {
|
|
|
+ image.src = e.target.result
|
|
|
+ }
|
|
|
+
|
|
|
+ // create FormData
|
|
|
+ var formData = new FormData()
|
|
|
+ formData.append('file_list', img)
|
|
|
+ formData.append('model_name', $('#model_name').val())
|
|
|
+ formData.append('img_size', $('#img_size').val())
|
|
|
+
|
|
|
+ $.ajax({
|
|
|
+ url: '/detect',
|
|
|
+ data: formData,
|
|
|
+ processData: false,
|
|
|
+ contentType: false,
|
|
|
+ type: 'POST',
|
|
|
+ success: function (json_result_data) {
|
|
|
+ json_result_data = json_result_data.replaceAll("'", '"')
|
|
|
+ data = JSON.parse(json_result_data)[0] //read json result of YOLO
|
|
|
+
|
|
|
+ //read the image, triggers image to load on canvas and bbox's to be drawn
|
|
|
+ reader.readAsDataURL(img)
|
|
|
+ },
|
|
|
+ error: function (xhr, ajaxOptions, thrownError) {
|
|
|
+ alert(
|
|
|
+ 'Error code ' +
|
|
|
+ xhr.status +
|
|
|
+ ': ' +
|
|
|
+ thrownError +
|
|
|
+ '\nMessage: ' +
|
|
|
+ JSON.parse(xhr.responseText)['message']
|
|
|
+ )
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+</script>
|
|
|
+{% endblock %}
|