├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml └── workflows │ └── welcome.yml ├── CNAME ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── index.html ├── script.js └── styles.css /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 🐞 2 | description: Bugs with PolygonZone 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for submitting a PolygonZone Bug Report! 9 | 10 | - type: checkboxes 11 | attributes: 12 | label: Search before asking 13 | description: > 14 | Please search the [issues](https://github.com/roboflow/polygonzone/issues) to see if a similar bug report already exists. 15 | options: 16 | - label: > 17 | I have searched the PolygonZone [issues](https://github.com/roboflow/polygonzone/issues) and found no similar bug report. 18 | required: true 19 | 20 | - type: textarea 21 | attributes: 22 | label: Bug 23 | description: Describe the issue that you are experiencing. 24 | placeholder: | 25 | 💡 ProTip! Include as much information as possible (screenshots, logs, tracebacks etc.) to receive the most helpful response. 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | attributes: 31 | label: Environment 32 | description: Please specify the browser and resolution you are using. 33 | placeholder: | 34 | - Browser: [e.g. Chrome, Safari] 35 | - Resolution: [e.g. 1920x1080] (if your bug relates to the UI at a particular resolution) 36 | validations: 37 | required: false 38 | 39 | - type: textarea 40 | attributes: 41 | label: Steps to Reproduce 42 | description: > 43 | Please provide a list of steps that can be followed to reproduce the bug you have encountered. If a bug is only present when an image is at a particular resolution, note the characteristics of the image (e.g. size, aspect ratio, etc.). 44 | placeholder: | 45 | ``` 46 | # Steps to Reproduce 47 | ``` 48 | validations: 49 | required: false 50 | 51 | - type: textarea 52 | attributes: 53 | label: Additional Information 54 | description: Anything else you would like to share? 55 | 56 | - type: checkboxes 57 | attributes: 58 | label: Are you willing to submit a PR? 59 | description: > 60 | (Optional) We encourage you to submit a [Pull Request](https://github.com/roboflow/polygonzone/pulls) (PR) to help improve PolygonZone for everyone, especially if you have a good understanding of how to implement a fix or a new feature. 61 | options: 62 | - label: Yes I'd like to help by submitting a PR! -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: 🤩 Feature Request 2 | description: Suggest a PolygonZone idea 3 | # title: " " 4 | labels: [enhancement] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for submitting a PolygonZone 🤩 Feature Request! 10 | 11 | - type: checkboxes 12 | attributes: 13 | label: Search before asking 14 | description: > 15 | Please search the [issues](https://github.com/roboflow/polygonzone/issues) to see if a similar feature request already exists. 16 | options: 17 | - label: > 18 | I have searched the PolygonZone [issues](https://github.com/roboflow/polygonzone/issues) and found no similar feature requests. 19 | required: true 20 | 21 | - type: textarea 22 | attributes: 23 | label: Description 24 | description: A short description of your feature. 25 | placeholder: | 26 | What new feature would you like to see in PolygonZone? 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | attributes: 32 | label: Use case 33 | description: | 34 | Describe the use case of your feature request. It will help us understand and prioritize the feature request. 35 | placeholder: | 36 | How would this feature be used, and who would use it? 37 | 38 | - type: textarea 39 | attributes: 40 | label: Additional 41 | description: Anything else you would like to share? 42 | 43 | - type: checkboxes 44 | attributes: 45 | label: Are you willing to submit a PR? 46 | description: > 47 | (Optional) We encourage you to submit a [Pull Request](https://github.com/roboflow/polygonzone/pulls) (PR) to help improve PolygonZone for everyone, especially if you have a good understanding of how to implement a fix or feature. 48 | options: 49 | - label: Yes I'd like to help by submitting a PR! -------------------------------------------------------------------------------- /.github/workflows/welcome.yml: -------------------------------------------------------------------------------- 1 | name: Welcome WorkFlow 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | pull_request_target: 7 | types: [opened] 8 | 9 | jobs: 10 | build: 11 | name: 👋 Welcome 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/first-interaction@v1.1.1 15 | with: 16 | repo-token: ${{ secrets.GITHUB_TOKEN }} 17 | issue-message: "Hello there, thank you for opening an Issue ! 🙏🏻 The team was notified and they will get back to you soon." 18 | pr-message: "Hello there, thank you for opening an PR ! 🙏🏻 The team was notified and they will get back to you soon." -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | polygonzone.roboflow.com -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to PolygonZone 🛠️ 2 | 3 | Thank you for your interest in contributing to PolygonZone! 4 | 5 | Our goal with PolygonZone is to solve a problem that comes up time and time again in computer vision: figuring out the coordinates of a polygon that you want to draw on an image, a particular issue when you're trying to mark zones of interest in an image. 6 | 7 | ## Contribution Guidelines 8 | 9 | We welcome contributions to: 10 | 11 | 1. Add a new feature to the web application (see more below). 12 | 3. Report bugs and issues in the project. 13 | 4. Submit a request for a new feature. 14 | 15 | ### Contributing Features 16 | 17 | PolygonZone is specifically designed to make it easy to retrieve the coordinates of a polygon that you want to draw on an image. If you have a feature that you think would: (i) improve the drawing experience; (ii) improve the general user experience; (iii) provide new import export functionalities or; (iv) otherwise improve PolygonZone, please submit an Issue to discuss the feature so the community can weigh in and assist. 18 | 19 | ## How to Contribute Changes 20 | 21 | First, fork this repository to your own GitHub account. Create a new branch that describes your changes (i.e. `show-coordinates`). Push your changes to the branch on your fork and then submit a pull request to this repository. 22 | 23 | When creating new functions, please ensure you have the following: 24 | 25 | 1. Comments where necessary that make it easier to understand how your code works. 26 | 2. A written test that the project maintainers can follow to verify your code works as expected. 27 | 28 | All pull requests will be reviewed by the maintainers of the project. We will provide feedback and ask for changes if necessary. -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Roboflow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 7 | 8 |

9 |
10 |
11 | 12 | 16 | 17 | 18 | 19 | 23 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 37 | 38 | 39 | 40 | 44 | 45 | 46 | 50 | 51 | 52 |
53 |
54 | 55 | ## About PolygonZone 📐 56 | 57 | PolygonZone lets you draw arbitrary polygons on an image and retrieve the coordinates of the points you have drawn. 58 | 59 | This application makes it easy to retrieve coordinates for Regions of Interest in computer vision applications. 60 | 61 | PolygonZone accompanies [Roboflow Supervision](https://github.com/roboflow/supervision), a Python library with a range of utilities that are useful in computer vision projects. 62 | 63 | Please note that PolygonZone is not an annotation tool. It is a tool for retrieving coordinates of polygons that you have drawn on an image. 64 | 65 | This application is designed for desktop use. 66 | 67 | ## Demo 68 | 69 | https://user-images.githubusercontent.com/37276661/218796838-3a66a61c-ac9d-40f5-97ff-0030a8bdc60d.mov 70 | 71 | ## Getting Started 🚀 72 | 73 | To use PolygonZone, open up the [PolygonZone web application](https://roboflow.github.io/polygonzone/). Then: 74 | 75 | 1. Upload an image onto which you want to draw a polygon. 76 | 2. Click on the points where you want to draw the polygon. 77 | 3. Click on the intial point or press "Enter" to save a polygon. 78 | 4. Continue to draw as many polygons as you need. 79 | 5. Copy the NumPy array or JSON object that contains the coordinates of the polygons you have drawn. 80 | 81 | ## Functionalities 82 | - You can zoom in and out of an image using the mouse wheel or a laptop track pad. 83 | - You can undo the last point pressing Ctrl/Cmd+Z 84 | - You can discard the last unsaved polygon pressing Esc 85 | 86 | ## Contributing 🤝 87 | 88 | We welcome contributions to the PolygonZone project. Please see the [Contributing Guidelines](CONTRIBUTING.md) for more information on how you can help to improve this project. 89 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | PolygonZone by Roboflow 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 38 |
39 |
40 |
41 |

PolygonZone

42 |

PolygonZone lets you calculate polygon points in an image.

43 |

How to use

44 |
    45 |
  1. Drop an image to the indicated area
  2. 46 |
  3. Select the desired mode: Press L to draw a line, or P to draw a polygon
  4. 47 |
  5. Click to draw polygon points. Press enter to finish the polygon.
  6. 48 |
49 |

Coordinates

50 |

Copy the points below, formatted as NumPy arrays, into your Python code.

51 | Copy Python to Clipboard 52 | 53 | 54 |
 55 |                             
 56 |                             
 57 |                         
58 |
59 | View JSON Points 60 |

JSON Points

61 | Copy JSON to Clipboard 62 |
 63 |                                 
 64 |                                 
 65 |                             
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | 75 | 76 | Polygon Mode 77 | (P) 78 | 79 |
80 |
81 | 82 | 83 | Line Mode 84 | (L) 85 | 86 |
87 |
88 | 89 | 90 | Edit Mode 91 | (E) 92 | 93 |
94 |
95 |
96 | 97 | 98 | Undo 99 | (Ctrl/⌘-Z) 100 | 101 |
102 |
103 | 104 | 105 | Discard current 106 | (Esc) 107 | 108 |
109 |
110 | 111 | 112 | Clear all polygons 113 | (Ctrl/⌘-E) 114 | 115 |
116 |
117 |
118 | 119 | 120 | Save image 121 | (Ctrl/⌘-S) 122 | 123 |
124 |
125 | 126 | 127 | Fullscreen 128 | (F) 129 | 130 |
131 |
132 |
133 | Coordinates 134 | 135 | x: --- | 136 | y: --- 137 | 138 |
139 |
140 |
141 |
142 | 143 |
144 |
145 |
146 |
147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | var color_choices = [ 2 | "#FF00FF", 3 | "#8622FF", 4 | "#FE0056", 5 | "#00FFCE", 6 | "#FF8000", 7 | "#00B7EB", 8 | "#FFFF00", 9 | "#0E7AFE", 10 | "#FFABAB", 11 | "#0000FF", 12 | "#CCCCCC", 13 | ]; 14 | // if you want choices to be kind of random, shuffle the array once here 15 | 16 | var radiansPer45Degrees = Math.PI / 4; 17 | 18 | var imageContainer = document.querySelector('.image-container'); 19 | var canvas = document.getElementById('canvas'); 20 | var mainCtx = canvas.getContext('2d'); 21 | var offScreenCanvas = document.createElement('canvas'); 22 | var offScreenCtx = offScreenCanvas.getContext('2d'); 23 | offScreenCanvas.width = canvas.width; 24 | offScreenCanvas.height = canvas.height; 25 | 26 | var img = new Image(); 27 | var rgb_color = color_choices[0]; 28 | var fill_color = 'rgba(0,0,0,0.35)'; 29 | 30 | var scaleFactor = 1; 31 | var scaleSpeed = 0.01; 32 | 33 | var points = []; 34 | var masterPoints = []; 35 | var masterColors = []; 36 | 37 | var drawMode; 38 | setDrawMode('polygon'); 39 | var constrainAngles = false; 40 | var showNormalized = false; 41 | 42 | function resetState() { 43 | points = []; 44 | masterPoints = []; 45 | masterColors = []; 46 | rgb_color = color_choices[0]; 47 | document.querySelector('#json').innerHTML = ''; 48 | document.querySelector('#python').innerHTML = ''; 49 | } 50 | 51 | 52 | var isFullscreen = false; 53 | var taskbarAndCanvas = document.querySelector('.right'); 54 | 55 | var editMode = false; 56 | var selectedPointIndex = -1; 57 | var selectedPolygonIndex = -1; 58 | 59 | 60 | function blitCachedCanvas() { 61 | mainCtx.clearRect(0, 0, canvas.width, canvas.height); 62 | mainCtx.drawImage(offScreenCanvas, 0, 0); 63 | } 64 | 65 | function clipboard(selector) { 66 | var copyText = document.querySelector(selector).innerText; 67 | // strip whitespace on left and right 68 | copyText = copyText.replace(/^\s+|\s+$/g, ''); 69 | navigator.clipboard.writeText(copyText); 70 | } 71 | 72 | function clearDrawings() { 73 | offScreenCtx.clearRect(0, 0, offScreenCanvas.width, offScreenCanvas.height); 74 | blitCachedCanvas(); 75 | } 76 | 77 | function isClockwise(vertices) { 78 | let sum = 0; 79 | for (let i = 0; i < vertices.length; i++) { 80 | const [x1, y1] = vertices[i]; 81 | const [x2, y2] = vertices[(i + 1) % vertices.length]; 82 | sum += (x2 - x1) * (y2 + y1); 83 | } 84 | return sum > 0; 85 | } 86 | 87 | function zoom(clicks) { 88 | var newScaleFactor = scaleFactor + clicks * scaleSpeed; 89 | newScaleFactor = Math.max(0.1, Math.min(newScaleFactor, 1)); 90 | 91 | const maxWidth = imageContainer.offsetWidth * 0.95; 92 | const maxHeight = imageContainer.offsetHeight * 0.95; 93 | 94 | let newWidth = img.width * newScaleFactor; 95 | let newHeight = img.height * newScaleFactor; 96 | 97 | if (newWidth > maxWidth) { 98 | newHeight = (maxWidth / newWidth) * newHeight; 99 | newWidth = maxWidth; 100 | newScaleFactor = newWidth / img.width; 101 | } 102 | 103 | if (newHeight > maxHeight) { 104 | newWidth = (maxHeight / newHeight) * newWidth; 105 | newHeight = maxHeight; 106 | newScaleFactor = newHeight / img.height; 107 | } 108 | 109 | scaleFactor = newScaleFactor; 110 | canvas.style.width = newWidth + 'px'; 111 | canvas.style.height = newHeight + 'px'; 112 | } 113 | 114 | function onPathClose() { 115 | canvas.style.cursor = 'default'; 116 | // we do this to avoid clearing overlapping polygons 117 | if (isClockwise(points)) { 118 | points = points.reverse(); 119 | } 120 | masterPoints.push(points); 121 | points = []; 122 | masterColors.push(rgb_color); 123 | drawAllPolygons(offScreenCtx); 124 | blitCachedCanvas(); 125 | rgb_color = color_choices[(masterColors.length) % (color_choices.length)]; 126 | } 127 | 128 | // placeholder image 129 | img.src = 'https://assets.website-files.com/5f6bc60e665f54545a1e52a5/63d3f236a6f0dae14cdf0063_drag-image-here.png'; 130 | img.onload = function() { 131 | scaleFactor = 0.69; 132 | canvas.style.width = img.width * scaleFactor + 'px'; 133 | canvas.style.height = img.height * scaleFactor + 'px'; 134 | canvas.width = img.width; 135 | canvas.height = img.height; 136 | offScreenCanvas.width = img.width; 137 | offScreenCanvas.height = img.height; 138 | offScreenCtx.drawImage(img, 0, 0); 139 | blitCachedCanvas(); 140 | }; 141 | 142 | function makeLine(ctx, x1, y1, x2, y2) { 143 | ctx.moveTo(x1, y1); 144 | ctx.lineTo(x2, y2); 145 | } 146 | 147 | function drawNode(ctx, x, y, stroke = null) { 148 | if (stroke) { 149 | ctx.strokeStyle = stroke; 150 | } 151 | ctx.beginPath(); 152 | ctx.arc(x, y, 5, 0, 2 * Math.PI); 153 | ctx.closePath(); 154 | ctx.fillStyle = 'white'; 155 | ctx.fill(); 156 | ctx.stroke(); 157 | } 158 | 159 | function getScaledCoords(e) { 160 | var rect = canvas.getBoundingClientRect(); 161 | var x = e.clientX - rect.left; 162 | var y = e.clientY - rect.top; 163 | return [x / scaleFactor, y / scaleFactor]; 164 | } 165 | 166 | function drawAllPolygons(ctx) { 167 | ctx.clearRect(0, 0, canvas.width, canvas.height); 168 | ctx.drawImage(img, 0, 0); 169 | // draw polygons as subpaths and fill all at once 170 | // we do this to avoid overlapping polygons becoming opaque 171 | ctx.beginPath(); 172 | ctx.fillStyle = fill_color; 173 | for (var i = 0; i < masterPoints.length; i++) { 174 | var newpoints = masterPoints[i]; 175 | for (var j = 1; j < newpoints.length; j++) { 176 | makeLine(ctx, newpoints[j - 1][0], newpoints[j - 1][1], newpoints[j][0], newpoints[j][1]); 177 | ctx.moveTo(newpoints[0][0], newpoints[0][1]); 178 | for (var j = 1; j < newpoints.length; j++) { 179 | ctx.lineTo(newpoints[j][0], newpoints[j][1]); 180 | } 181 | makeLine(ctx, newpoints[newpoints.length - 1][0], newpoints[newpoints.length - 1][1], newpoints[0][0], newpoints[0][1]); 182 | } 183 | } 184 | ctx.fill(); 185 | 186 | ctx.lineWidth = 5; 187 | ctx.lineJoin = 'bevel'; 188 | for (var i = 0; i < masterPoints.length; i++) { 189 | var newpoints = masterPoints[i]; 190 | ctx.strokeStyle = masterColors[i]; 191 | 192 | ctx.beginPath(); 193 | for (var j = 1; j < newpoints.length; j++) { 194 | makeLine(ctx, newpoints[j - 1][0], newpoints[j - 1][1], newpoints[j][0], newpoints[j][1]); 195 | ctx.moveTo(newpoints[0][0], newpoints[0][1]); 196 | for (var j = 1; j < newpoints.length; j++) { 197 | ctx.lineTo(newpoints[j][0], newpoints[j][1]); 198 | } 199 | } 200 | ctx.closePath(); 201 | ctx.stroke(); 202 | 203 | // draw arc around each point 204 | for (var j = 0; j < newpoints.length; j++) { 205 | drawNode(ctx, newpoints[j][0], newpoints[j][1]); 206 | } 207 | } 208 | } 209 | 210 | function getParentPoints() { 211 | var parentPoints = []; 212 | for (var i = 0; i < masterPoints.length; i++) { 213 | parentPoints.push(masterPoints[i]); 214 | } 215 | parentPoints.push(points); 216 | return parentPoints; 217 | } 218 | 219 | function findClosestPoint(x, y) { 220 | let minDist = Infinity; 221 | let closestPoint = null; 222 | let polygonIndex = -1; 223 | let pointIndex = -1; 224 | 225 | for (let i = 0; i < masterPoints.length; i++) { 226 | for (let j = 0; j < masterPoints[i].length; j++) { 227 | const [px, py] = masterPoints[i][j]; 228 | const dist = Math.sqrt((x - px) ** 2 + (y - py) ** 2); 229 | if (dist < minDist && dist < 10 / scaleFactor) { // grab radius 230 | minDist = dist; 231 | closestPoint = [px, py]; 232 | polygonIndex = i; 233 | pointIndex = j; 234 | } 235 | } 236 | } 237 | 238 | return { point: closestPoint, polygonIndex, pointIndex }; 239 | } 240 | 241 | window.addEventListener('keyup', function(e) { 242 | if (e.key === 'Shift') { 243 | constrainAngles = false; 244 | } 245 | }); 246 | 247 | document.querySelector('#copyPythonButton').addEventListener('click', function(e) { 248 | e.preventDefault(); 249 | clipboard("#python"); 250 | }); 251 | 252 | document.querySelector('#copyJSONButton').addEventListener('click', function(e) { 253 | e.preventDefault(); 254 | clipboard("#json"); 255 | }); 256 | 257 | canvas.addEventListener('dragover', function(e) { 258 | e.preventDefault(); 259 | }); 260 | 261 | canvas.addEventListener('wheel', function(e) { 262 | e.preventDefault() 263 | var delta = Math.sign(e.deltaY); 264 | zoom(delta); 265 | }); 266 | 267 | canvas.addEventListener('mouseleave', function(e) { 268 | var xcoord = document.querySelector('#x'); 269 | var ycoord = document.querySelector('#y'); 270 | xcoord.innerHTML = ''; 271 | ycoord.innerHTML = ''; 272 | }); 273 | 274 | canvas.addEventListener('mousemove', function(e) { 275 | var [x, y] = getScaledCoords(e); 276 | x = Math.round(x); 277 | y = Math.round(y); 278 | 279 | // update x y coords 280 | var xcoord = document.querySelector('#x'); 281 | var ycoord = document.querySelector('#y'); 282 | 283 | if(constrainAngles && points.length > 0) { 284 | var lastPoint = points[points.length - 1]; 285 | var dx = x - lastPoint[0]; 286 | var dy = y - lastPoint[1]; 287 | var angle = Math.atan2(dy, dx); 288 | var length = Math.sqrt(dx * dx + dy * dy); 289 | const snappedAngle = Math.round(angle / radiansPer45Degrees) * radiansPer45Degrees; 290 | var new_x = lastPoint[0] + length * Math.cos(snappedAngle); 291 | var new_y = lastPoint[1] + length * Math.sin(snappedAngle); 292 | x = Math.round(new_x); 293 | y = Math.round(new_y); 294 | } 295 | 296 | // sometimes, a mousemove event is leaving the canvas and has coordinates outside the canvas 297 | // however, due to the cursors being used, we do not need to check if it is larger than canvas.width or canvas.height 298 | if (x < 0 || y < 0 ){ 299 | xcoord.innerHTML = ''; 300 | ycoord.innerHTML = ''; 301 | return; 302 | } 303 | xcoord.innerHTML = x; 304 | ycoord.innerHTML = y; 305 | 306 | var ctx = mainCtx; 307 | ctx.lineWidth = 5; 308 | ctx.lineJoin = 'bevel'; 309 | ctx.fillStyle = 'white'; 310 | 311 | // if cursor is crosshair, draw line from last point to cursor 312 | if (canvas.style.cursor == 'crosshair') { 313 | blitCachedCanvas(); 314 | 315 | for (var i = 0; i < points.length - 1; i++) { 316 | ctx.strokeStyle = rgb_color; 317 | ctx.beginPath(); 318 | ctx.lineJoin = 'bevel'; 319 | makeLine(ctx, points[i][0], points[i][1], points[i + 1][0], points[i + 1][1]); 320 | ctx.closePath(); 321 | ctx.stroke(); 322 | 323 | drawNode(ctx, points[i][0], points[i][1]); 324 | } 325 | 326 | 327 | if ((points.length > 0 && drawMode == "polygon") || (points.length > 0 && points.length < 2 && drawMode == "line")) { 328 | ctx.beginPath(); 329 | ctx.lineJoin = 'bevel'; 330 | ctx.strokeStyle = rgb_color; 331 | makeLine(ctx, points[points.length - 1][0], points[points.length - 1][1], x, y); 332 | ctx.closePath(); 333 | ctx.stroke(); 334 | 335 | drawNode(ctx, points[i][0], points[i][1]); 336 | } 337 | } 338 | 339 | if (editMode && selectedPointIndex !== -1) { 340 | masterPoints[selectedPolygonIndex][selectedPointIndex] = [x, y]; 341 | drawAllPolygons(offScreenCtx); 342 | blitCachedCanvas(); 343 | writePoints(getParentPoints()); 344 | } 345 | }); 346 | 347 | canvas.addEventListener('drop', function(e) { 348 | e.preventDefault(); 349 | var file = e.dataTransfer.files[0]; 350 | 351 | // only allow image files 352 | var supportedImageTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp']; 353 | if (!supportedImageTypes.includes(file.type)) { 354 | alert('Only PNG, JPEG, JPG, and WebP files are allowed.'); 355 | return; 356 | } 357 | 358 | var reader = new FileReader(); 359 | reader.onload = function(event) { 360 | img.src = event.target.result; 361 | }; 362 | reader.readAsDataURL(file); 363 | 364 | img.onload = function() { 365 | // reset state to initial values 366 | resetState(); 367 | 368 | // draw loaded image on canvas 369 | scaleFactor = 0.25; 370 | canvas.style.width = img.width * scaleFactor + 'px'; 371 | canvas.style.height = img.height * scaleFactor + 'px'; 372 | canvas.width = img.width; 373 | canvas.height = img.height; 374 | offScreenCanvas.width = img.width; 375 | offScreenCanvas.height = img.height; 376 | 377 | const maxWidth = imageContainer.offsetWidth * 0.95; 378 | const maxHeight = imageContainer.offsetHeight * 0.95; 379 | 380 | let newWidth = img.width; 381 | let newHeight = img.height; 382 | 383 | if (newWidth > maxWidth) { 384 | newHeight = (maxWidth / newWidth) * newHeight; 385 | newWidth = maxWidth; 386 | } 387 | 388 | if (newHeight > maxHeight) { 389 | newWidth = (maxHeight / newHeight) * newWidth; 390 | newHeight = maxHeight; 391 | } 392 | 393 | scaleFactor = newWidth / img.width; 394 | 395 | canvas.style.width = newWidth + 'px'; 396 | canvas.style.height = newHeight + 'px'; 397 | canvas.style.borderRadius = '10px'; 398 | offScreenCtx.drawImage(img, 0, 0); 399 | blitCachedCanvas(); 400 | }; 401 | }); 402 | 403 | function writePoints(parentPoints) { 404 | var normalized = []; 405 | 406 | // if normalized is true, normalize all points 407 | var imgHeight = img.height; 408 | var imgWidth = img.width; 409 | if (showNormalized) { 410 | for (var i = 0; i < parentPoints.length; i++) { 411 | var normalizedPoints = []; 412 | for (var j = 0; j < parentPoints[i].length; j++) { 413 | normalizedPoints.push([ 414 | Math.round(parentPoints[i][j][0] / imgWidth * 100) / 100, 415 | Math.round(parentPoints[i][j][1] / imgHeight * 100) / 100 416 | ]); 417 | } 418 | normalized.push(normalizedPoints); 419 | } 420 | parentPoints = normalized; 421 | } 422 | 423 | // clean empty points 424 | parentPoints = parentPoints.filter(points => !!points.length); 425 | 426 | if (!parentPoints.length) { 427 | document.querySelector('#python').innerHTML = ''; 428 | document.querySelector('#json').innerHTML; 429 | return; 430 | } 431 | 432 | // create np.array list 433 | var code_template = `[\n${parentPoints.map(function(points) { 434 | return ` np.array([${points.map(function(point) { 435 | return `[${point[0]}, ${point[1]}]`;}).join(', ')}])`; 436 | }).join(',\n')}\n]`; 437 | 438 | document.querySelector('#python').innerHTML = code_template; 439 | 440 | var json_template = `{\n${parentPoints.map(function(points) { 441 | return ` [${points.map(function(point) { 442 | return `{"x": ${point[0]}, "y": ${point[1]}}`;}).join(', ')}]`; 443 | }).join(',\n')}\n}`; 444 | 445 | document.querySelector('#json').innerHTML = json_template; 446 | } 447 | 448 | canvas.addEventListener('mousedown', function(e) { 449 | var [x, y] = getScaledCoords(e); 450 | x = Math.round(x); 451 | y = Math.round(y); 452 | 453 | if (editMode) { 454 | const { point, polygonIndex, pointIndex } = findClosestPoint(x, y); 455 | if (point) { 456 | selectedPointIndex = pointIndex; 457 | selectedPolygonIndex = polygonIndex; 458 | canvas.style.cursor = 'grabbing'; 459 | } 460 | } else { 461 | // click handling for drawing mode 462 | canvas.style.cursor = 'crosshair'; 463 | 464 | if(constrainAngles && points.length > 0) { 465 | var lastPoint = points[points.length - 1]; 466 | var dx = x - lastPoint[0]; 467 | var dy = y - lastPoint[1]; 468 | var angle = Math.atan2(dy, dx); 469 | var length = Math.sqrt(dx * dx + dy * dy); 470 | const snappedAngle = Math.round(angle / radiansPer45Degrees) * radiansPer45Degrees; 471 | var new_x = lastPoint[0] + length * Math.cos(snappedAngle); 472 | var new_y = lastPoint[1] + length * Math.sin(snappedAngle); 473 | x = Math.round(new_x); 474 | y = Math.round(new_y); 475 | } 476 | 477 | if (points.length > 2 && drawMode == "polygon") { 478 | distX = x - points[0][0]; 479 | distY = y - points[0][1]; 480 | // stroke is 3px and centered on the circle (i.e. 1/2 * 3px) and arc radius is 481 | if(Math.sqrt(distX * distX + distY * distY) <= 6.5) { 482 | onPathClose(); 483 | return; 484 | } 485 | } 486 | 487 | points.push([x, y]); 488 | 489 | drawNode(mainCtx, x, y, rgb_color); 490 | 491 | if(drawMode == "line" && points.length == 2) { 492 | onPathClose(); 493 | } 494 | 495 | // concat all points into one array 496 | var parentPoints = []; 497 | 498 | for (var i = 0; i < masterPoints.length; i++) { 499 | parentPoints.push(masterPoints[i]); 500 | } 501 | // add "points" 502 | if(points.length > 0) { 503 | parentPoints.push(points); 504 | } 505 | 506 | writePoints(parentPoints); 507 | } 508 | }); 509 | 510 | canvas.addEventListener('mouseup', function(e) { 511 | if (editMode) { 512 | selectedPointIndex = -1; 513 | selectedPolygonIndex = -1; 514 | canvas.style.cursor = 'move'; 515 | } 516 | }); 517 | 518 | document.querySelector('#normalize-checkbox').addEventListener('change', function(e) { 519 | showNormalized = e.target.checked; 520 | var parentPoints = getParentPoints(); 521 | writePoints(parentPoints); 522 | }); 523 | 524 | function setDrawMode(mode) { 525 | drawMode = mode; 526 | setEditMode(false); 527 | canvas.style.cursor = 'crosshair'; 528 | document.querySelectorAll('.t-mode').forEach(el => el.classList.remove('active')); 529 | document.querySelector(`#mode-${mode}`).classList.add('active'); 530 | } 531 | 532 | function setEditMode(editEnabled) { 533 | editMode = editEnabled; 534 | canvas.style.cursor = editEnabled ? 'move' : 'crosshair'; 535 | document.querySelectorAll('.t-mode').forEach(el => el.classList.remove('active')); 536 | document.querySelector(`#mode-${editEnabled ? 'edit' : drawMode}`).classList.add('active'); 537 | } 538 | 539 | document.querySelector('#mode-polygon').addEventListener('click', function(e) { 540 | setDrawMode('polygon'); 541 | }) 542 | 543 | document.querySelector('#mode-line').addEventListener('click', function(e) { 544 | setDrawMode('line'); 545 | }) 546 | 547 | document.querySelector('#mode-edit').addEventListener('click', function(e) { 548 | setEditMode(true); 549 | }); 550 | 551 | document.addEventListener('keydown', function(e) { 552 | if (e.key == 'l' || e.key == 'L') { 553 | setDrawMode('line'); 554 | } 555 | if (e.key == 'p' || e.key == 'P') { 556 | setDrawMode('polygon'); 557 | } 558 | if (e.key == 'e' || e.key == 'E') { 559 | setEditMode(true); 560 | } 561 | }); 562 | 563 | function rewritePoints() { 564 | var parentPoints = getParentPoints(); 565 | writePoints(parentPoints); 566 | } 567 | 568 | function highlightButtonInteraction (buttonId) { 569 | document.querySelector(buttonId).classList.add('active'); 570 | setTimeout(() => document.querySelector(buttonId).classList.remove('active'), 100); 571 | } 572 | 573 | function undo() { 574 | highlightButtonInteraction('#undo'); 575 | 576 | if (points.length > 0) { 577 | points.pop(); 578 | blitCachedCanvas(); 579 | rewritePoints(); 580 | 581 | if(points.length === 0){ 582 | return; 583 | } 584 | 585 | var ctx = mainCtx; 586 | ctx.strokeStyle = rgb_color; 587 | ctx.fillStyle = 'white'; 588 | if (points.length === 1) { 589 | drawNode(ctx, points[0][0], points[0][1]); 590 | } 591 | else { 592 | drawNode(ctx, points[0][0], points[0][1]); 593 | for (var i = 0; i < points.length - 1; i++) { 594 | makeLine(ctx, points[i][0], points[i][1], points[i + 1][0], points[i + 1][1]); 595 | ctx.stroke(); 596 | drawNode(ctx, points[i + 1][0], points[i + 1][1]); 597 | } 598 | } 599 | } 600 | } 601 | 602 | document.querySelector('#undo').addEventListener('click', function(e) { 603 | undo(); 604 | }) 605 | 606 | function discardCurrentPolygon () { 607 | highlightButtonInteraction('#discard-current'); 608 | points = []; 609 | blitCachedCanvas(); 610 | rewritePoints(); 611 | } 612 | 613 | document.querySelector('#discard-current').addEventListener('click', function(e) { 614 | discardCurrentPolygon(); 615 | }) 616 | 617 | function clearAll() { 618 | highlightButtonInteraction('#clear') 619 | resetState(); 620 | // reset main and offscreen canvases 621 | mainCtx.clearRect(0, 0, canvas.width, canvas.height); 622 | offScreenCtx.clearRect(0, 0, offScreenCanvas.width, offScreenCanvas.height); 623 | mainCtx.drawImage(img, 0, 0); 624 | offScreenCtx.drawImage(img, 0, 0); 625 | points = []; 626 | masterPoints = []; 627 | masterColors = []; 628 | rgb_color = color_choices[0]; 629 | document.querySelector('#jsonCode').innerHTML = ''; 630 | document.querySelector('#pythonCode').innerHTML = ''; 631 | } 632 | 633 | document.querySelector('#clear').addEventListener('click', function(e) { 634 | clearAll(); 635 | }) 636 | 637 | function saveImage () { 638 | highlightButtonInteraction('#save-image'); 639 | 640 | var link = document.createElement('a'); 641 | link.download = 'image.png'; 642 | link.href = canvas.toDataURL(); 643 | link.click(); 644 | } 645 | 646 | document.querySelector('#save-image').addEventListener('click', function(e) { 647 | saveImage(); 648 | }) 649 | 650 | function toggleFullscreen() { 651 | highlightButtonInteraction('#fullscreen'); 652 | 653 | if (!isFullscreen) { 654 | if (taskbarAndCanvas.requestFullscreen) { 655 | taskbarAndCanvas.requestFullscreen(); 656 | } else if (taskbarAndCanvas.webkitRequestFullscreen) { // Safari 657 | taskbarAndCanvas.webkitRequestFullscreen(); 658 | } else if (taskbarAndCanvas.msRequestFullscreen) { // IE/Edge 659 | taskbarAndCanvas.msRequestFullscreen(); 660 | } 661 | } else { 662 | if (document.exitFullscreen) { 663 | document.exitFullscreen(); 664 | } else if (document.webkitExitFullscreen) { // Safari 665 | document.webkitExitFullscreen(); 666 | } else if (document.msExitFullscreen) { // IE/Edge 667 | document.msExitFullscreen(); 668 | } 669 | } 670 | } 671 | 672 | document.addEventListener('fullscreenchange', function() { 673 | isFullscreen = document.fullscreenElement !== null; 674 | }); 675 | 676 | document.querySelector('#fullscreen').addEventListener('click', function(e) { 677 | toggleFullscreen(); 678 | }); 679 | 680 | window.addEventListener('keydown', function(e) { 681 | if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { 682 | e.preventDefault(); 683 | e.stopImmediatePropagation(); 684 | undo(); 685 | } 686 | 687 | if (e.key === 'Shift') { 688 | constrainAngles = true; 689 | } 690 | 691 | if (e.key === 'Escape') { 692 | discardCurrentPolygon(); 693 | } 694 | 695 | if (e.key === 'e' && (e.ctrlKey || e.metaKey)) { 696 | clearAll(); 697 | } 698 | 699 | if (e.key === 's' && (e.ctrlKey || e.metaKey)) { 700 | e.preventDefault(); 701 | e.stopImmediatePropagation(); 702 | 703 | saveImage(); 704 | } 705 | 706 | if (e.key === 'Enter') { 707 | if(points.length > 2) { 708 | onPathClose(); 709 | } 710 | } 711 | 712 | if (e.key === 'f' || e.key === 'F') { 713 | toggleFullscreen(); 714 | } 715 | }) -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | canvas { 2 | display: block; 3 | margin: 0 auto; 4 | } 5 | * { 6 | font-family: Tahoma, Arial, Helvetica, sans-serif; 7 | } 8 | pre { 9 | max-height: 200px; 10 | overflow: auto; 11 | height: 200px; 12 | background-color:rgba(17,24,39,0.1); 13 | padding: 10px; 14 | max-width: 100%; 15 | overflow-y: scroll; 16 | text-wrap: wrap; 17 | text-align: left; 18 | } 19 | body { 20 | background: white; 21 | height: 100vh; 22 | margin: 0; 23 | padding: 0; 24 | } 25 | main { 26 | display: flex; 27 | flex-direction: column; 28 | height: 100vh; 29 | margin: 0; 30 | padding: 0; 31 | } 32 | h1 { 33 | font-size: 28px; 34 | } 35 | h1, h2 { 36 | color: rgb(103, 6, 206); 37 | } 38 | details { 39 | width: 100%; 40 | margin: 0; 41 | text-align: left; 42 | } 43 | nav { 44 | border-bottom: 1px solid lightgrey; 45 | display: flex; 46 | justify-content: space-between; 47 | } 48 | nav ul { 49 | list-style-type: none; 50 | padding: 0; 51 | padding-left: 1em; 52 | } 53 | nav ul li { 54 | display: inline; 55 | margin-right: 10px; 56 | } 57 | summary { 58 | border: 2px solid #00ffce; 59 | color: #000; 60 | padding: 10px; 61 | margin-bottom: 10px; 62 | border-radius: 10px; 63 | } 64 | a { 65 | color: #5400ec; 66 | text-decoration: none; 67 | } 68 | .content { 69 | display: flex; 70 | flex-grow: 1; 71 | } 72 | .left { 73 | flex: 40%; 74 | padding: 0 1em; 75 | border-right: 1px solid #e5e7eb; 76 | box-sizing: border-box; 77 | } 78 | .right { 79 | flex: 60%; 80 | padding: 0 2em; 81 | box-sizing: border-box; 82 | display: flex; 83 | flex-direction: column; 84 | } 85 | .image-container { 86 | flex-grow: 1; 87 | } 88 | @media screen and (max-width: 900px) { 89 | .content { 90 | display: block; 91 | } 92 | .left, .right { 93 | padding: 0; 94 | } 95 | .left { 96 | border-right: 0; 97 | } 98 | 99 | canvas { 100 | margin-bottom: 50px; 101 | } 102 | .right { 103 | width: 45em; 104 | margin: auto; 105 | } 106 | } 107 | .controls { 108 | text-align: left; 109 | } 110 | .space { 111 | padding-left: 50px; 112 | } 113 | .widgetButton { 114 | display: inline-block; 115 | margin-right: 8px; 116 | margin-left: 8px; 117 | padding: 12px 24px; 118 | -webkit-align-self: center; 119 | -ms-flex-item-align: center; 120 | -ms-grid-row-align: center; 121 | align-self: center; 122 | border-radius: 4px; 123 | background-color: #7733f4; 124 | box-shadow: 0 3px 3px 0 rgb(55 65 81 / 20%); 125 | font-family: proxima-nova,sans-serif; 126 | color: #fff; 127 | font-weight: 700; 128 | text-align: center; 129 | letter-spacing: .5px; 130 | } 131 | .banner { 132 | max-width: 100%; 133 | margin-bottom: 20px; 134 | max-height: 150px; 135 | } 136 | .grid { 137 | display: grid; 138 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 139 | grid-gap: 20px; 140 | margin-top: 30px; 141 | max-width: 40em; 142 | margin: auto; 143 | margin-bottom: 30px; 144 | } 145 | .card { 146 | background: #fff; 147 | border-radius: 5px; 148 | box-shadow: 0 2px 3px 1px rgb(0 0 0 / 10%); 149 | overflow: hidden; 150 | text-align: left; 151 | padding: 10px; 152 | } 153 | .card img { 154 | width: 100%; 155 | } 156 | .card h4 { 157 | margin-bottom: 20px; 158 | color: #1f2937; 159 | font-size: 24px; 160 | line-height: 1.2; 161 | font-weight: 600; 162 | } 163 | .card a { 164 | color: inherit; 165 | text-decoration: none; 166 | } 167 | .card p { 168 | margin-bottom: 20px; 169 | padding-top: 10px; 170 | border-top: 1px solid #e5e7eb; 171 | color: #666; 172 | line-height: 1.4; 173 | font-weight: 400; 174 | } 175 | 176 | footer { 177 | margin-top: 50px; 178 | } 179 | 180 | .section { 181 | margin-bottom: 50px; 182 | } 183 | 184 | li { 185 | margin-bottom: 4px; 186 | } 187 | 188 | .show-normalized { 189 | background-color: #5400ec; 190 | opacity: 0.8; 191 | color: white; 192 | font-weight: 600; 193 | display: inline-block; 194 | padding: 15px; 195 | border-radius: 10px; 196 | } 197 | 198 | /* Taskbar */ 199 | .taskbar-container { 200 | display: flex; 201 | width: 100%; 202 | align-items: center; 203 | flex-direction: column; 204 | margin-bottom: 1em; 205 | margin-top: 1em; 206 | } 207 | 208 | .taskbar { 209 | display: flex; 210 | background-color: #7733f4; 211 | color: #f1edf8; 212 | 213 | border-radius: 5px; 214 | padding: 6px; 215 | 216 | justify-content:space-around; 217 | box-shadow: 0 2px 3px 1px rgb(0 0 0 / 20%); 218 | } 219 | 220 | .taskbar .t-icon { 221 | display: flex; 222 | flex-direction: column; 223 | padding: 8px 24px; 224 | border-radius: 5px; 225 | transition: all .15s ease-in-out; 226 | } 227 | 228 | .taskbar .t-icon:hover { 229 | cursor: pointer; 230 | background-color: #8f54fd; 231 | } 232 | 233 | .taskbar .t-icon.active { 234 | background-color: #f4efff; 235 | color: #7733f4; 236 | } 237 | 238 | .taskbar .t-icon .ti-caption { 239 | font-size: 11px; 240 | margin-top: 8px; 241 | display: flex; 242 | height: 100%; 243 | flex-direction: column; 244 | justify-content: center; 245 | } 246 | 247 | .taskbar .t-divider { 248 | background-color: #8145f1; 249 | width: 1px; 250 | } 251 | 252 | /* Coordinates */ 253 | .coordinates { 254 | display: flex; 255 | flex-direction: column; 256 | text-align: left; 257 | 258 | width: 100%; 259 | box-sizing: border-box; 260 | text-align: center; 261 | padding: 8px 16px; 262 | border-radius: 5px; 263 | margin-top: 8px; 264 | 265 | font-size: 1em; 266 | box-shadow: 0 2px 3px 1px rgb(0 0 0 / 20%); 267 | background-color: #fbf9ff; 268 | } 269 | 270 | .coordinates .cc-title { 271 | margin-bottom: 4px; 272 | color: #5400ec; 273 | } 274 | 275 | .coordinates .cc-coord { 276 | margin-bottom: 2px; 277 | color: #989898; 278 | } 279 | 280 | .coordinates .cc-value { 281 | color: #000; 282 | } 283 | 284 | 285 | /* Helpers */ 286 | .ta-left { 287 | text-align: left; 288 | } 289 | 290 | .mb-1 { 291 | margin-bottom: 8px; 292 | } 293 | 294 | .mb-2 { 295 | margin-bottom: 16px; 296 | } 297 | 298 | .mb-3 { 299 | margin-bottom: 24px; 300 | } 301 | 302 | .mt-1 { 303 | margin-top: 8px; 304 | } 305 | 306 | .mt-2 { 307 | margin-top: 16px; 308 | } 309 | 310 | .mt-3 { 311 | margin-top: 24px; 312 | } --------------------------------------------------------------------------------