├── .babelrc ├── .env.template ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api ├── __init__.py ├── annotation_api.py ├── image_api.py ├── project_api.py └── static_api.py ├── config.py ├── data ├── __init__.py └── image_adapter.py ├── docker-compose-db.yml ├── docker-compose.yml ├── documentation ├── image-annotation.png └── project_view.png ├── frontend ├── index.html └── src │ ├── components │ ├── Breadcrumb.js │ ├── Popup.js │ ├── forms │ │ ├── AnnotationTypeSelector.js │ │ ├── DownloadForm.js │ │ ├── ImageUploadForm.js │ │ └── SettingsPopup.js │ ├── images │ │ ├── FrameControl.js │ │ ├── FramePlayer.js │ │ ├── ImageAnnotation.js │ │ └── ImageList.js │ └── project │ │ ├── AnnotationTypeList.js │ │ ├── AnnotationTypeMigration.js │ │ └── ProjectSettingsImpex.js │ ├── containers │ ├── App.js │ ├── ImageView.js │ ├── ProjectContainer.js │ └── ProjectOverview.js │ ├── index.js │ ├── mixins.js │ ├── redux │ ├── annotations.js │ ├── images.js │ ├── projects.js │ └── reducers.js │ ├── tools │ ├── EditTool.js │ ├── EraserTool.js │ ├── FreeHandTool.js │ ├── LineTool.js │ ├── PolygonTool.js │ ├── SplineTool.js │ └── common.js │ └── util.js ├── migrate_data.py ├── package.json ├── requirements.txt ├── runner.py ├── setup.cfg ├── static └── favicon.png ├── storage ├── __init__.py ├── annotation_store.py ├── image_store.py └── project_store.py └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | ["@babel/plugin-proposal-decorators", { "legacy": true }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # folder where to store the images 2 | IMAGE_FOLDER=_images 3 | # db name 4 | MONGO_DB=annotator 5 | 6 | # running MongoDB natively on localhost 7 | # MONGO_URL=mongodb://localhost:27017 8 | 9 | # running MongoDB in docker-compose 10 | # MONGO_URL=mongodb://annotator:annotator@localhost:27018 11 | 12 | 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # folders 2 | node_modules/ 3 | _logs/ 4 | controller/ 5 | api/ 6 | storage/ 7 | templates/ 8 | util/ 9 | static/ 10 | travis/ 11 | .npm/ 12 | 13 | # extensions 14 | *.json 15 | *.html 16 | *.md 17 | *.log 18 | *.yml 19 | *.py 20 | *.sh 21 | *.sql 22 | 23 | # files 24 | Dockerfile 25 | Makefile 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "extends": [ 9 | "airbnb", 10 | "prettier", 11 | "prettier/react" 12 | ], 13 | "plugins": [ 14 | "react", 15 | "import", 16 | "prettier" 17 | ], 18 | "rules": { 19 | "no-console": "off", 20 | "no-sequences": "off", 21 | "no-param-reassign": [ "error", { "props": false } ], 22 | "no-underscore-dangle": [ "warn", { "allow": [ "_id", "_rev", "_path" ] } ], 23 | "no-unused-vars": [ "warn", { "vars": "local", "args": "none" } ], 24 | "no-unused-expressions": [ "error", { "allowTernary": true } ], 25 | "no-plusplus": [ "error", { "allowForLoopAfterthoughts": true } ], 26 | "no-duplicate-imports": 0, 27 | "no-restricted-globals": "off", 28 | "handle-callback-err": "error", 29 | "valid-jsdoc": [ 30 | "error", 31 | { 32 | "prefer": { 33 | "arg": "param", 34 | "return": " returns" 35 | }, 36 | "requireReturnType": false, 37 | "requireReturn": false 38 | } 39 | ], 40 | "react/jsx-filename-extension": [ 1, { "extensions": [ ".js", ".jsx" ] } ], 41 | "react/prop-types": "off", 42 | "react/jsx-indent": "off", 43 | "react/jsx-indent-props": "off", 44 | "react/prefer-stateless-function": "off", 45 | "react/destructuring-assignment": "off", 46 | "import/no-extraneous-dependencies": "off", 47 | "import/no-unresolved": "off", 48 | "import/extensions": "off", 49 | "jsx-a11y/no-static-element-interactions": "off", 50 | "jsx-a11y/no-noninteractive-element-interactions": "off", 51 | "jsx-a11y/label-has-associated-control": "off", 52 | "jsx-a11y/label-has-for": "off", 53 | "jsx-a11y/click-events-have-key-events": "off", 54 | "jsx-a11y/alt-text": "off", 55 | "jsx-a11y/mouse-events-have-key-events": "off", 56 | "class-methods-use-this": "off", 57 | 58 | "prettier/prettier": [ 59 | "error", { 60 | "tabWidth": 4, 61 | "semi": false, 62 | "singleQuote": false, 63 | "trailingComma": "es5", 64 | "printWidth": 120, 65 | "comma-dangle": "always" 66 | } 67 | ] 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /.idea 3 | /_logs 4 | /static/* 5 | !/static/favicon.png 6 | /templates/index.html 7 | travis/pip.conf 8 | _images/ 9 | _data/ 10 | package-lock.json 11 | _migration/ 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | .hypothesis/ 60 | .pytest_cache/ 61 | 62 | # Translations 63 | *.mo 64 | *.pot 65 | 66 | # Django stuff: 67 | *.log 68 | local_settings.py 69 | db.sqlite3 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # SageMath parsed files 94 | *.sage.py 95 | 96 | # Environments 97 | .venv 98 | env/ 99 | venv/ 100 | ENV/ 101 | env.bak/ 102 | venv.bak/ 103 | 104 | # Spyder project settings 105 | .spyderproject 106 | .spyproject 107 | 108 | # Rope project settings 109 | .ropeproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | 117 | npm-debug.log* 118 | yarn-debug.log* 119 | yarn-error.log* 120 | 121 | # Runtime data 122 | pids 123 | *.pid 124 | *.seed 125 | *.pid.lock 126 | 127 | # Directory for instrumented libs generated by jscoverage/JSCover 128 | lib-cov 129 | 130 | # Coverage directory used by tools like istanbul 131 | coverage 132 | 133 | # nyc test coverage 134 | .nyc_output 135 | 136 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 137 | .grunt 138 | 139 | # Bower dependency directory (https://bower.io/) 140 | bower_components 141 | 142 | # node-waf configuration 143 | .lock-wscript 144 | 145 | # Compiled binary addons (https://nodejs.org/api/addons.html) 146 | build/Release 147 | 148 | # Dependency directories 149 | node_modules/ 150 | jspm_packages/ 151 | 152 | # TypeScript v1 declaration files 153 | typings/ 154 | 155 | # Optional npm cache directory 156 | .npm 157 | 158 | # Optional eslint cache 159 | .eslintcache 160 | 161 | # Optional REPL history 162 | .node_repl_history 163 | 164 | # Output of 'npm pack' 165 | *.tgz 166 | 167 | # Yarn Integrity file 168 | .yarn-integrity 169 | 170 | # next.js build output 171 | .next -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | # install nodejs 4 | RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - 5 | RUN apt-get install -y nodejs 6 | 7 | # install npm 8 | RUN curl -L https://npmjs.org/install.sh | sh 9 | 10 | # declare app directory 11 | WORKDIR /app 12 | 13 | # copy favicon 14 | RUN mkdir -p static 15 | COPY static/favicon.png ./static 16 | 17 | # install dependencies 18 | COPY requirements.txt . 19 | RUN pip install -r requirements.txt 20 | 21 | COPY package.json . 22 | RUN npm i 23 | 24 | # copy javascript code 25 | COPY frontend ./frontend 26 | COPY .babelrc . 27 | COPY webpack.config.js . 28 | 29 | # install NPM deps and build the frontend 30 | RUN npm run build 31 | 32 | # copy python code 33 | COPY api ./api 34 | COPY data ./data 35 | COPY storage ./storage 36 | COPY config.py . 37 | COPY runner.py . 38 | 39 | # start server 40 | ENTRYPOINT ["python3"] 41 | CMD ["runner.py"] 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL:=/bin/bash 2 | include .env 3 | 4 | 5 | start: 6 | python3 runner.py 7 | 8 | dev: 9 | IS_DEBUG=1 python3 runner.py 10 | 11 | install-deps: 12 | pip3 install -r requirements.txt --user 13 | npm install 14 | 15 | clean: 16 | rm -f _logs/*.log* 17 | rm -f static/*.hot-update.js* 18 | 19 | check-lint: 20 | find . -name '*.py' | while read file; do \ 21 | pycodestyle $$file; \ 22 | done; \ 23 | 24 | lint: 25 | find . -name '*.py' | while read file; do \ 26 | pycodestyle $$file; \ 27 | if [[ $$? != 0 ]]; then exit $$?; fi \ 28 | done; \ 29 | 30 | frontend: 31 | npm run hot-client 32 | 33 | build: 34 | npm run build 35 | 36 | docker-build: 37 | docker build -t ilfrich/annotator . 38 | 39 | docker-push: docker-build 40 | docker push ilfrich/annotator 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Annotator 2 | 3 | General purpose tool to create image annotations for machine learning purposes. 4 | 5 | > **The system supports single images and frame sets (multiple images _of the same size_ provided as zip file).** 6 | 7 | Annotations can be downloaded as JSON file and used in machine learning tools to train image analytic models. 8 | 9 | [![Demo Video](http://img.youtube.com/vi/L9HzpUxJNc0/0.jpg)](https://www.youtube.com/watch?v=L9HzpUxJNc0 "Demo Video") 10 | _click to view demo video_ 11 | 12 | **Table of Contents** 13 | 14 | 1. [Requirements](#requirements) 15 | 2. [Installation](#installation) 16 | 3. [Docker Service](#docker-service) 17 | 4. [Running the App](#running-the-app) 18 | 5. [Environment Config](#environment-config) 19 | 6. [Tech Stack](#tech-stack) 20 | 7. [Migration / Copy](#migration--copy) 21 | 22 | **Quick Start** 23 | 24 | If you just want to run the Annotator, the easiest way is [Docker](https://docs.docker.com/get-docker/): 25 | 26 | 1. Simply download the file: https://github.com/ilfrich/annotator/blob/master/docker-compose.yml 27 | 2. And run `docker-compose up` - _this will download all required docker images and start up the system_ 28 | 29 | _Note: images and the database with annotations are retained when you shut down the system_ 30 | 31 | **Project Overview** 32 | 33 | ![Project Overview](documentation/project_view.png) 34 | 35 | **Single Image Annotations** 36 | 37 | ![Image Annotation](documentation/image-annotation.png) 38 | 39 | ## Requirements 40 | 41 | - Python3.6+ 42 | - NodeJS / npm 43 | - MongoDB (Docker or Native) 44 | 45 | **Or** run the entire solution as [docker service](#docker-service) 46 | 47 | ## Installation 48 | 49 | **If you decide to run the solution in Docker only, you can skip this section and go directly to 50 | [docker service](#docker-service)** 51 | 52 | > **Before you start the installation and run the app, you need to prepare the `.env` file. Simply copy the 53 | `.env.template` file to get started:** 54 | > `cp .env.template .env` 55 | > Then you can edit this file, which won't be committed to GIT to adjust to your local setup and requirements. 56 | > Please see the [Environment Config](#environment-config) section for more details. 57 | 58 | First, try to run: 59 | 60 | ```bash 61 | make install-deps 62 | ``` 63 | 64 | That should install the Python (pip) dependencies and Javascript (npm) dependencies. 65 | This assumes, that your Python/Pip binaries are `python3` and `pip3`. 66 | 67 | **Manual Installation** 68 | 69 | If above doesn't work or shows errors, install the dependencies separately: 70 | 71 | _Javascript dependencies:_ 72 | 73 | ```bash 74 | npm install 75 | ``` 76 | 77 | _Python dependencies:_ 78 | 79 | ```bash 80 | pip install -r requirements.txt 81 | ``` 82 | 83 | ## Docker Service 84 | 85 | You can run the whole solution as docker environment. 86 | 87 | **Start** 88 | 89 | - Run `docker-compose up` - this will download the MongoDB image and Annotator image, if you don't already have it, and 90 | run MongoDB + the Annotator container containing the annotator app. 91 | 92 | **Build** 93 | 94 | - If you want to build the container yourself, run `docker build -t ilfrich/annotator .` (ensure the build finishes 95 | successful) 96 | 97 | ## Running the App 98 | 99 | Regardless of whether you run the app as [docker service](#docker-service) or standalone (see below), the app will be 100 | available at http://localhost:5555 101 | 102 | ### Frontend 103 | 104 | If you just want to compile the frontend once and then serve it via the backend (i.e. production mode), simply run: 105 | 106 | ```bash 107 | npm run build 108 | ``` 109 | 110 | This will produce an index.js containing all the frontend code in the `/static` directory and put the index.html in the 111 | `/templates` folder. Those 2 directories are used by the Flask app to deliver the frontend components. 112 | 113 | ### Database 114 | 115 | MongoDB can either be run locally as native database or via docker through `docker-compose`. 116 | 117 | **Option 1: Native MongoDB** 118 | 119 | If you have MongoDB installed on your local, simply adjust the `.env` file and use: 120 | 121 | ``` 122 | MONGO_URL=mongodb://localhost:27017 123 | ``` 124 | 125 | If your local database uses a custom username and password, simply include them in the URL: 126 | 127 | ``` 128 | MONGO_URL=mongodb://myusername:mypassword@localhost:27017 129 | ``` 130 | 131 | **Option 2: MongoDB through Docker** 132 | 133 | If you don't want to install MongoDB on your local, simply install **docker** (and **docker-compose**). Then you adjust 134 | the `.env` file and use: 135 | 136 | ``` 137 | MONGO_URL=mongodb://annotator:annotator@localhost:27018 138 | ``` 139 | 140 | To start the database docker container only use: 141 | 142 | ```bash 143 | docker-compose -f docker-compose-db.yml up 144 | ``` 145 | 146 | The database will be persisted between shutdown / startup. 147 | 148 | ### Backend 149 | 150 | The backend's entry point is the script `runner.py` on the root of the project. To run the backend, simply execute: 151 | 152 | ```bash 153 | make start 154 | ``` 155 | 156 | Again, if you Python binary differs from `python3`, simply run: 157 | 158 | ```bash 159 | python runner.py 160 | ``` 161 | 162 | (and replace `python` with whatever you use as binary) 163 | 164 | - This'll serve the Flask app via: http://localhost:5555 165 | 166 | **Frontend Development** 167 | 168 | The frontend can be continuously re-compiled whenever you change the code. 169 | In a separate bash window, simply run: 170 | 171 | ```bash 172 | make frontend 173 | ``` 174 | 175 | Or 176 | 177 | ```bash 178 | npm run hot-client 179 | ``` 180 | 181 | This will run the `webpack` watcher, which will observe the `/frontend/src` folder for changes and re-compile the 182 | frontend when changes have occurred. 183 | 184 | In case of compilation errors, this bash window will also tell you what is wrong 185 | with your code. 186 | 187 | _Do not close this window while you're developing, or you quit the watcher._ 188 | 189 | ## Environment Config 190 | 191 | The Flask app is using an `.env` file to load environment variables which specify database access. 192 | Check the `config.py` for all currently supported environment variables. 193 | 194 | **Essential configuration:** 195 | 196 | - **`MONGO_URL`** specifies the database access (host, port and credentials) 197 | - **`MONGO_DB`** specifies the database name 198 | - **`IMAGE_FOLDER`** specifies the folder where to store the images (Note: they get stored in a sub-directory for each 199 | project). Default: `_images` 200 | 201 | Developers: You can easily extend this and add getters for additional environment configuration and add those to your 202 | `.env` file. Please provide meaningful defaults for all additional config variables (_except 3rd party service 203 | credentials_) 204 | 205 | ## Tech Stack 206 | 207 | **Backend** 208 | 209 | - **Flask** framework for hosting API endpoints and delivering the frontend 210 | - **pymongo** for MongoDB access 211 | 212 | **Frontend** 213 | 214 | - **React** basic framework for the frontend 215 | - **Redux** a global store for the frontend, used for data exchange with the API and to avoid handing down data through 216 | component hierarchies 217 | - **Webpack** and **Babel** to transpile the frontend into a single `index.js`, which gets included by the `index.html` 218 | - **Moment.JS** the standard library for date/time handling in JavaScript 219 | - **S Alert** a basic notification library 220 | - **ESLint** and **Prettier** for linting Javascript code and auto-format 221 | 222 | ## Migration / Copy 223 | 224 | If you want to copy a project (or multiple) from one annotator system to another, you can use the `migrate_data.py` 225 | script. 226 | 227 | Simply execute `python migrate_data.py` 228 | 229 | The script will lead the user through a dialog to provide the source URL (URL where you want to copy projects from) and 230 | target URL (URL of the system where you want to copy/clone the project(s) to). 231 | 232 | If you run this locally and want to copy the data to a server (let's call the server foobar.com), you would provide the 233 | source URL: `http://localhost:5555` and target URL: `http://foobar.com:5555`. 234 | 235 | Then it will scan for existing projects in the source system and print out a list of all projects. You can select which 236 | projects you want to replicate or provide "0", if you want all projects to be migrated. For multiple projects, but not 237 | all, you can simply provide the numbers in front of the project name separated by space (e.g. `1 2 5` to migrate 238 | project 1, 2 and 5 - corresponding to the numbers in the list). 239 | 240 | Once the migration has started, you simply have to wait. It will print out debug information about the progress. Note 241 | that migration of larger projects can take a while (several minutes). 242 | 243 | If you see a Python error during migration, there is not much assistance / information returned about what the problem 244 | is. You usually would see just Python errors. Check connectivity to all systems and potentially debug the script itself 245 | to find out what's wrong. Potential issues are: connectivity issues to source or target system, file system access 246 | issues (in the _migration folder) - check that you don't have an explorer window open showing the migration folder or 247 | a shell/bash open looking at that folder. 248 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilfrich/annotator/c5f6add10288975e4472515d8921c8d0dabecda0/api/__init__.py -------------------------------------------------------------------------------- /api/annotation_api.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, request, abort 2 | from pbu import list_to_json 3 | from storage.annotation_store import Annotation 4 | 5 | 6 | def register_endpoints(app, stores): 7 | 8 | annotation_store = stores["annotations"] 9 | project_store = stores["projects"] 10 | image_store = stores["images"] 11 | 12 | def _get_project(project_id): 13 | project = project_store.get(project_id) 14 | if project is None: 15 | abort(404) 16 | return project 17 | 18 | def _get_image(image_id): 19 | image = image_store.get(image_id) 20 | if image is None: 21 | abort(404) 22 | return image 23 | 24 | @app.route("/api/projects//images//annotations", methods=["GET"]) 25 | def get_annotation_for_image(project_id, image_id): 26 | # parents 27 | _ = _get_project(project_id) 28 | image = _get_image(image_id) 29 | # return annotation 30 | annotation = annotation_store.get_by_image(image_id) 31 | if image.num_frames is None: 32 | if len(annotation) == 0: 33 | return jsonify({}) 34 | else: 35 | return jsonify(annotation[0].to_json()) 36 | else: 37 | return jsonify(list_to_json(annotation)) 38 | 39 | @app.route("/api/projects//images//annotations", methods=["POST"]) 40 | def create_annotations(project_id, image_id): 41 | # parents 42 | project = _get_project(project_id) 43 | _ = _get_image(image_id) 44 | # parse body and save annotation 45 | body = request.get_json() 46 | instance = Annotation.from_json(body) 47 | instance.image_id = image_id 48 | instance.project_id = project_id 49 | annotation_id = annotation_store.create(instance.to_json()) 50 | # update project 51 | project.annotation_count += 1 52 | project_store.update_counts(project) 53 | return jsonify(annotation_store.get(annotation_id).to_json()) 54 | 55 | @app.route("/api/projects//images//annotations/", methods=["POST"]) 56 | def update_annotation(project_id, image_id, annotation_id): 57 | _ = _get_project(project_id) 58 | _ = _get_image(image_id) 59 | annotation = annotation_store.get(annotation_id) 60 | if annotation is None: 61 | abort(404) 62 | annotation = Annotation.from_json(request.get_json()) 63 | annotation_store.update_full(annotation) 64 | return jsonify(annotation.to_json()) 65 | 66 | @app.route("/api/projects//images//annotations/", methods=["DELETE"]) 67 | def delete_annotation(project_id, image_id, annotation_id): 68 | # parents 69 | project = _get_project(project_id) 70 | _ = _get_image(image_id) 71 | # check annotation exists 72 | annotation = annotation_store.get(annotation_id) 73 | if annotation is None: 74 | abort(404) 75 | # delete annotation 76 | annotation_store.delete(annotation_id) 77 | # update project 78 | project.annotation_count -= 1 79 | project_store.update_counts(project) 80 | return jsonify({}) 81 | -------------------------------------------------------------------------------- /api/image_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import PIL.Image 3 | import zipfile 4 | from operator import itemgetter 5 | from config import get_image_folder 6 | from flask import jsonify, request, abort, send_file 7 | from pbu import list_to_json 8 | from storage.image_store import Image 9 | from data.image_adapter import ImageAdapter 10 | 11 | 12 | def get_image_path(image, image_num=None): 13 | if image.num_frames is None: 14 | # normal image 15 | return os.path.join(get_image_folder(), 16 | image.project_id, 17 | "{}{}".format(image.id, ImageAdapter.get_image_extensions()[image.content_type])) 18 | frame = image_num 19 | if frame is None: 20 | frame = 0 21 | return os.path.join(get_image_folder(), 22 | image.project_id, 23 | image.id, 24 | "{}{}".format(frame, ImageAdapter.get_image_extensions()[image.content_type])) 25 | 26 | 27 | def _ensure_project_image_exists(project_id): 28 | # check image folders 29 | image_folder = get_image_folder() 30 | if not os.path.isdir(image_folder): 31 | os.mkdir(image_folder) 32 | project_folder = os.path.join(image_folder, project_id) 33 | if not os.path.isdir(project_folder): 34 | os.mkdir(project_folder) 35 | 36 | 37 | def _upload_frame_set(image_store, project_store, project, label, zip_file): 38 | project_id = project.id 39 | _ensure_project_image_exists(project_id) 40 | 41 | image = Image() 42 | image.label = label 43 | image.original_file_names = {} 44 | image.project_id = project_id 45 | image_id = image_store.create(image.to_json()) 46 | image.id = image_id 47 | 48 | # save zip file in project folder 49 | zip_path = os.path.join(get_image_folder(), project_id, "{}.zip".format(image_id)) 50 | zip_file.save(zip_path) 51 | 52 | # extract zip file 53 | extract_dir = os.path.join(get_image_folder(), project_id, image_id) 54 | os.mkdir(extract_dir) 55 | with zipfile.ZipFile(zip_path, "r") as zip_ref: 56 | zip_ref.extractall(extract_dir) 57 | 58 | # capture list of files and mime types 59 | mime_types = {} 60 | file_names = [] 61 | for file in os.listdir(extract_dir): 62 | file_names.append(file) 63 | current_mime_type = ImageAdapter.get_mime_type(file) 64 | if current_mime_type is not None: 65 | if current_mime_type not in mime_types: 66 | mime_types[current_mime_type] = 0 67 | mime_types[current_mime_type] += 1 68 | 69 | # get mime type and file extension 70 | all_types = [] 71 | for mime_type in mime_types: 72 | all_types.append({"type": mime_type, "count": mime_types[mime_type]}) 73 | sorted_types = list(sorted(all_types, key=lambda x: x["count"], reverse=True)) 74 | if len(sorted_types) == 0: 75 | raise ValueError("Zip file contains no valid images") 76 | mime_type = sorted_types[0]["type"] 77 | file_extension = ImageAdapter.get_image_extensions()[mime_type] 78 | 79 | # filter out non images 80 | file_names = list(filter(lambda x: ImageAdapter.get_mime_type(x) == mime_type, file_names)) 81 | 82 | # update meta image 83 | image.content_type = mime_type 84 | image.num_frames = len(file_names) 85 | image_store.update_full(image) 86 | 87 | # sort by length and then file name 88 | for index, frame in enumerate(sorted(list(map(lambda fn: (len(fn), fn), file_names)), key=itemgetter(0, 1))): 89 | # rename into 0.jpg, 1.jpg, ... 90 | os.rename(os.path.join(extract_dir, frame[1]), os.path.join(extract_dir, "{}{}".format(index, file_extension))) 91 | image.original_file_names[str(index)] = frame[1] 92 | 93 | # get image dimensions 94 | stored_image = PIL.Image.open(get_image_path(image, 0)) 95 | width, height = stored_image.size 96 | image_store.update_dimension(image.id, width, height) 97 | image_store.update_original_file_names(image.id, image.original_file_names) 98 | 99 | image.width = width 100 | image.height = height 101 | 102 | # update project 103 | project.image_count += 1 104 | project_store.update_counts(project) 105 | 106 | # clean up 107 | os.unlink(zip_path) 108 | return image 109 | 110 | 111 | def _upload_single_image(image_store, project_store, project, label, image_file): 112 | project_id = project.id 113 | _ensure_project_image_exists(project_id) 114 | 115 | image = Image() 116 | image.label = label 117 | image.original_file_names = image_file.filename 118 | image.project_id = project_id 119 | image.content_type = image_file.mimetype 120 | 121 | # create meta image 122 | image_id = image_store.create(image.to_json()) 123 | image.id = image_id 124 | 125 | image_path = get_image_path(image) 126 | 127 | # store file 128 | image_file.save(image_path) 129 | 130 | # update meta information 131 | stored_image = PIL.Image.open(image_path) 132 | width, height = stored_image.size 133 | image_store.update_dimension(image.id, width, height) 134 | 135 | # update project 136 | project.image_count += 1 137 | project_store.update_counts(project) 138 | # return meta image 139 | return image_store.get(image_id) 140 | 141 | 142 | def register_endpoints(app, stores): 143 | project_store = stores["projects"] 144 | image_store = stores["images"] 145 | annotation_store = stores["annotations"] 146 | 147 | def _get_project(project_id): 148 | project = project_store.get(project_id) 149 | if project is None: 150 | abort(404) 151 | return project 152 | 153 | @app.route("/image//", methods=["GET"]) 154 | def get_image_blob(project_id, image_id): 155 | _ = _get_project(project_id) 156 | image = image_store.get(image_id) 157 | if image is None: 158 | abort(404) 159 | image_path = get_image_path(image) 160 | if not os.path.exists(image_path): 161 | abort(404) 162 | return send_file(image_path) 163 | 164 | @app.route("/image///", methods=["GET"]) 165 | def get_image_frame_blob(project_id, image_id, frame_num): 166 | _ = _get_project(project_id) 167 | image = image_store.get(image_id) 168 | if image is None: 169 | abort(404) 170 | if image.num_frames is None or image.num_frames - 1 < frame_num: 171 | abort(400) 172 | image_path = get_image_path(image, frame_num) 173 | if not os.path.exists(image_path): 174 | abort(404) 175 | return send_file(image_path) 176 | 177 | @app.route("/api/projects//images", methods=["GET"]) 178 | def get_images(project_id): 179 | _ = _get_project(project_id) 180 | return jsonify(list_to_json(image_store.get_by_project(project_id))) 181 | 182 | @app.route("/api/projects//images/", methods=["GET"]) 183 | def get_image(project_id, image_id): 184 | _ = _get_project(project_id) 185 | img = image_store.get(image_id) 186 | if img is None: 187 | abort(404) 188 | return jsonify(img.to_json()) 189 | 190 | @app.route("/api/projects//images", methods=["POST"]) 191 | def upload_image(project_id): 192 | # check project 193 | project = _get_project(project_id) 194 | if "file" not in request.files: 195 | abort(400) 196 | new_image = request.files["file"] 197 | label = request.form.get("label") 198 | if new_image.mimetype in ["application/zip", "application/x-zip-compressed"]: 199 | # uploading frame set 200 | return jsonify(_upload_frame_set(image_store, project_store, project, label, new_image).to_json()) 201 | # upload single image 202 | return jsonify(_upload_single_image(image_store, project_store, project, label, new_image).to_json()) 203 | 204 | @app.route("/api/projects//images/", methods=["DELETE"]) 205 | def delete_image(project_id, image_id): 206 | project = _get_project(project_id) 207 | image = image_store.get(image_id) 208 | if image is None: 209 | abort(404) 210 | # delete image 211 | image_store.delete(image_id) 212 | if image.num_frames is not None: 213 | for i in range(0, image.num_frames): 214 | os.unlink(get_image_path(image, i)) 215 | os.rmdir(os.path.join(get_image_folder(), project_id, image.id)) 216 | else: 217 | os.remove(get_image_path(image)) 218 | # delete annotations 219 | annotations = annotation_store.get_by_image(image_id) 220 | if annotations is not None: 221 | if isinstance(annotations, list): 222 | for anno in annotations: 223 | annotation_store.delete(anno.id) 224 | else: 225 | annotation_store.delete(annotations.id) 226 | # update project 227 | project.image_count -= 1 228 | project_store.update_counts(project) 229 | return jsonify({}) 230 | 231 | @app.route("/api/projects//images/", methods=["POST"]) 232 | def update_image_label(project_id, image_id): 233 | _ = _get_project(project_id) 234 | image = image_store.get(image_id) 235 | if image is None: 236 | abort(404) 237 | 238 | body = request.get_json() 239 | if "label" not in body: 240 | abort(400) 241 | 242 | image_store.update_label(image_id, body["label"]) 243 | return jsonify(image_store.get(image_id).to_json()) 244 | -------------------------------------------------------------------------------- /api/project_api.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from config import get_image_folder 4 | from flask import jsonify, request, abort 5 | from pbu import list_to_json 6 | from storage.project_store import Project 7 | 8 | 9 | def register_endpoints(app, stores): 10 | 11 | project_store = stores["projects"] 12 | image_store = stores["images"] 13 | annotation_store = stores["annotations"] 14 | 15 | @app.route("/api/projects", methods=["GET"]) 16 | def get_projects(): 17 | return jsonify(list_to_json(project_store.get_all())) 18 | 19 | @app.route("/api/projects", methods=["POST"]) 20 | def create_project(): 21 | body = request.get_json() 22 | instance = Project.from_json(body) 23 | project_id = project_store.create(instance.to_json()) 24 | return jsonify(project_store.get(project_id).to_json()) 25 | 26 | @app.route("/api/projects/", methods=["DELETE"]) 27 | def delete_project(project_id): 28 | project_store.delete(project_id) 29 | # delete images 30 | images = image_store.get_by_project(project_id) 31 | for image in images: 32 | image_store.delete(image.id) 33 | # delete annotations 34 | annotations = annotation_store.get_by_project(project_id) 35 | for annotation in annotations: 36 | annotation_store.delete(annotation.id) 37 | # delete image folder 38 | project_image_folder = os.path.join(get_image_folder(), project_id) 39 | if os.path.isdir(project_image_folder): 40 | shutil.rmtree(project_image_folder) 41 | return jsonify({ 42 | "projectId": project_id, 43 | "deleted": True, 44 | }) 45 | 46 | @app.route("/api/projects//annotation-types", methods=["POST"]) 47 | def update_project_annotation_types(project_id): 48 | existing = project_store.get(project_id) 49 | if existing is None: 50 | abort(404) 51 | 52 | body = request.get_json() 53 | project_store.update_annotation_types(project_id, body["annotationTypes"]) 54 | return jsonify(project_store.get(project_id).to_json()) 55 | 56 | @app.route("/api/projects//settings", methods=["POST"]) 57 | def import_project_settings(project_id): 58 | existing = project_store.get(project_id) 59 | if existing is None: 60 | abort(404) 61 | body = request.get_json() 62 | settings = body["settings"] 63 | import_mapping = body["importMapping"]["annotationTypes"] 64 | final_annotation_types = existing.annotation_types 65 | 66 | # handle existing annotations 67 | for annotation_type in import_mapping: 68 | if import_mapping[annotation_type] != 1: 69 | # will be migrated or just removed 70 | del final_annotation_types[annotation_type] 71 | 72 | # handle migration of annotation types 73 | if import_mapping[annotation_type] == -1: 74 | # remove 75 | annotation_store.remove_annotation_type(project_id, annotation_type) 76 | elif import_mapping[annotation_type] == 1: 77 | # keep this 78 | pass 79 | else: 80 | # migrate annotations 81 | annotation_store.migrate_annotation_type(project_id, annotation_type, import_mapping[annotation_type]) 82 | 83 | # add all the new annotation types 84 | for annotation_type in settings["annotationTypes"]: 85 | final_annotation_types[annotation_type] = settings["annotationTypes"][annotation_type] 86 | 87 | # update project 88 | project_store.update_annotation_types(project_id, final_annotation_types) 89 | return jsonify(project_store.get(project_id).to_json()) -------------------------------------------------------------------------------- /api/static_api.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | from config import is_debug 3 | 4 | 5 | def register_endpoints(app): 6 | """ 7 | Registers all the endpoints used by the frontend to deliver the index.html in all cases. 8 | :param app: the flask app 9 | """ 10 | @app.route("/", methods=["GET"]) 11 | @app.route("/projects/", methods=["GET"]) 12 | @app.route("/projects//images/", methods=["GET"]) 13 | def get_index(project_id=None, image_id=None): 14 | """ 15 | Each call to the API, which doesn't start with `/api` will be covered by this function providing the index.html 16 | to the caller. The index.html will load the index.js in the browser, which will render the frontend. The 17 | frontend will then decide what view to render. The backend is not responsible for that. 18 | 19 | **IMPORTANT** 20 | This function needs to be updated whenever new frontend routes are added to the React router. You can provide 21 | multiple @app.route(..) lines for multiple frontend routes that all just return the frontend (because the 22 | frontend has it's own router which decides what page to render) 23 | 24 | :return: the index.html as file (basically delivering the whole frontend) 25 | """ 26 | return render_template("index.html") 27 | 28 | # prevent caching of the frontend during development 29 | if is_debug(): 30 | @app.after_request 31 | def add_header(r): 32 | """ 33 | Add headers to both force latest IE rendering engine or Chrome Frame, 34 | and also to cache the rendered page for 10 minutes. 35 | """ 36 | r.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 37 | r.headers["Pragma"] = "no-cache" 38 | r.headers["Expires"] = "0" 39 | r.headers['Cache-Control'] = 'public, max-age=0' 40 | return r 41 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | from pytz import timezone 3 | import os 4 | 5 | 6 | DEFAULTS = { 7 | "MONGO_URL": "mongodb://localhost:27017", 8 | "MONGO_DB": "annotator", 9 | 10 | "IMAGE_FOLDER": "_images", 11 | 12 | "LOG_FOLDER": "_logs", 13 | "IS_DEBUG": False, 14 | } 15 | 16 | DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" 17 | DATE_FORMAT = "%Y-%m-%d" 18 | 19 | 20 | def load_config(): 21 | # read out existing os environment 22 | load_dotenv() 23 | config = { 24 | "MONGO_URL": os.getenv("MONGO_URL"), 25 | "MONGO_DB": os.getenv("MONGO_DB"), 26 | 27 | "IMAGE_FOLDER": os.getenv("IMAGE_FOLDER"), 28 | 29 | "LOG_FOLDER": os.getenv("LOG_FOLDER"), 30 | "IS_DEBUG": os.getenv("IS_DEBUG") == "1", 31 | } 32 | 33 | # apply defaults for missing config params 34 | for key in DEFAULTS: 35 | if key not in config or config[key] is None: 36 | config[key] = DEFAULTS[key] 37 | 38 | # check if log folder exists 39 | if not os.path.isdir(config["LOG_FOLDER"]): 40 | os.mkdir(config["LOG_FOLDER"]) 41 | 42 | return config 43 | 44 | 45 | def get_log_folder(): 46 | config = load_config() 47 | return config["LOG_FOLDER"] 48 | 49 | 50 | def get_image_folder(): 51 | config = load_config() 52 | return config["IMAGE_FOLDER"] 53 | 54 | 55 | def get_mongodb_config(): 56 | config = load_config() 57 | return config["MONGO_URL"], config["MONGO_DB"] 58 | 59 | 60 | def is_debug(): 61 | config = load_config() 62 | return config["IS_DEBUG"] 63 | -------------------------------------------------------------------------------- /data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilfrich/annotator/c5f6add10288975e4472515d8921c8d0dabecda0/data/__init__.py -------------------------------------------------------------------------------- /data/image_adapter.py: -------------------------------------------------------------------------------- 1 | _EXTENSIONS = { 2 | "image/png": ".png", 3 | "image/jpg": ".jpg", 4 | "image/gif": ".gif", 5 | "image/jpeg": ".jpg", # secondary fallback 6 | } 7 | 8 | 9 | class ImageAdapter: 10 | @staticmethod 11 | def get_mime_type(file_name): 12 | for mimetype in _EXTENSIONS: 13 | if _EXTENSIONS[mimetype] in file_name: 14 | return mimetype 15 | return None 16 | 17 | @staticmethod 18 | def get_image_extensions(): 19 | return _EXTENSIONS 20 | -------------------------------------------------------------------------------- /docker-compose-db.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | mongodb_container: 4 | image: mongo:latest 5 | environment: 6 | MONGO_INITDB_ROOT_USERNAME: annotator 7 | MONGO_INITDB_ROOT_PASSWORD: annotator 8 | ports: 9 | - 127.0.0.1:27018:27017 10 | volumes: 11 | - mongodb_data_container:/data/db 12 | 13 | volumes: 14 | mongodb_data_container: 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | services: 3 | mongodb_container: 4 | image: mongo:latest 5 | environment: 6 | MONGO_INITDB_ROOT_USERNAME: annotator 7 | MONGO_INITDB_ROOT_PASSWORD: annotator 8 | ports: 9 | - 127.0.0.1:27018:27017 10 | volumes: 11 | - mongodb_data_container:/data/db 12 | annotator: 13 | image: ilfrich/annotator:latest 14 | links: 15 | - mongodb_container 16 | environment: 17 | MONGO_URL: mongodb://annotator:annotator@mongodb_container:27017 18 | volumes: 19 | - image_folder:/app/_images 20 | ports: 21 | - 5555:5555 22 | depends_on: 23 | - mongodb_container 24 | 25 | volumes: 26 | mongodb_data_container: 27 | image_folder: 28 | -------------------------------------------------------------------------------- /documentation/image-annotation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilfrich/annotator/c5f6add10288975e4472515d8921c8d0dabecda0/documentation/image-annotation.png -------------------------------------------------------------------------------- /documentation/project_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilfrich/annotator/c5f6add10288975e4472515d8921c8d0dabecda0/documentation/project_view.png -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The Annotator 8 | 9 | 10 | 11 | 82 | 83 | 84 |
85 | 86 | -------------------------------------------------------------------------------- /frontend/src/components/Breadcrumb.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Link } from "react-router-dom" 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import mixins from "../mixins" 5 | 6 | const style = { 7 | inline: { 8 | display: "inline", 9 | }, 10 | menuLink: { 11 | ...mixins.noLink, 12 | padding: "0px 10px", 13 | }, 14 | extension: { 15 | padding: "0px 10px", 16 | display: "inline-block", 17 | }, 18 | } 19 | 20 | const Breadcrumb = props => ( 21 |

22 | 23 | Home 24 | 25 | {props.project != null ? ( 26 |
27 | 28 | 29 | {props.project.name} 30 | 31 |
32 | ) : null} 33 | {props.current != null ? ( 34 |
35 | 36 | 37 | {props.current} 38 | 39 |
40 | ) : null} 41 | {props.children != null ? ( 42 |
43 | 44 |
{props.children}
45 |
46 | ) : null} 47 |

48 | ) 49 | 50 | export default Breadcrumb 51 | -------------------------------------------------------------------------------- /frontend/src/components/Popup.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import mixins from "../mixins" 3 | 4 | const style = { 5 | backdrop: zIndex => ({ 6 | zIndex: `${zIndex || 600}`, 7 | position: "fixed", 8 | top: "0px", 9 | left: "0px", 10 | width: "100%", 11 | height: "100%", 12 | display: "block", 13 | background: "rgba(33, 33, 33, 0.7)", 14 | }), 15 | popup: zIndex => ({ 16 | zIndex: `${zIndex ? zIndex + 1 : 601}`, 17 | position: "fixed", 18 | top: "100px", 19 | left: "25%", 20 | width: "50%", 21 | margin: "auto", 22 | background: "#eee", 23 | }), 24 | title: { 25 | background: "#004", 26 | color: "#fff", 27 | borderBottom: "1px solid #ccc", 28 | padding: "30px", 29 | fontSize: "20px", 30 | }, 31 | body: { 32 | padding: "30px", 33 | maxHeight: "450px", 34 | overflowY: "scroll", 35 | }, 36 | buttons: { 37 | padding: "30px", 38 | textAlign: "right", 39 | }, 40 | } 41 | 42 | /** 43 | * Renders a popup on a backdrop with a custom tile and injectable body and a set of buttons. 44 | * @param {{yes: function, no: function, ok: function, cancel: function, title: string, children: array, zIndex: number}} props: 45 | * the properties passed into this component. Either provide ('yes' and 'no') keys or ('ok' and 'cancel') keys. The 46 | * 'zIndex' is optional and defaults to 600. 47 | * @returns {object} a React component's rendering 48 | */ 49 | const Popup = props => { 50 | return ( 51 |
52 |
53 |
54 |
{props.title}
55 |
{props.children}
56 |
57 | {/* delete dialog */} 58 | {props.yes != null && props.no != null 59 | ? [ 60 | , 63 | , 66 | ] 67 | : null} 68 | {/* ok dialog */} 69 | {props.ok != null && props.cancel != null 70 | ? [ 71 | , 74 | , 77 | ] 78 | : null} 79 |
80 |
81 |
82 | ) 83 | } 84 | 85 | export default Popup 86 | -------------------------------------------------------------------------------- /frontend/src/components/forms/AnnotationTypeSelector.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { mixins } from "quick-n-dirty-react" 3 | 4 | const style = { 5 | grid: { 6 | display: "grid", 7 | gridTemplateColumns: "300px 500px", 8 | }, 9 | legendItem: { 10 | paddingTop: "45px", 11 | display: "inline-block", 12 | marginLeft: "5px", 13 | fontSize: "16px", 14 | }, 15 | color: code => ({ 16 | display: "inline-block", 17 | width: "13px", 18 | height: "13px", 19 | background: code, 20 | border: "1px solid #aaa", 21 | marginLeft: "8px", 22 | marginRight: "3px", 23 | }), 24 | } 25 | 26 | const AnnotationTypeSelector = props => { 27 | const changeType = ev => { 28 | const value = ev.target.value === "" ? null : ev.target.value 29 | props.changeType(value) 30 | } 31 | 32 | return ( 33 |
34 |
35 | 36 |
37 | 45 |
46 |
47 |
48 | {Object.keys(props.project.annotationTypes || {}).map(type => ( 49 |
50 | 51 | {type} 52 |
53 | ))} 54 |
55 |
56 | ) 57 | } 58 | 59 | export default AnnotationTypeSelector 60 | -------------------------------------------------------------------------------- /frontend/src/components/forms/DownloadForm.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 3 | import { mixins } from "quick-n-dirty-react" 4 | 5 | const style = { 6 | toolIcon: { 7 | ...mixins.clickable, 8 | padding: "5px", 9 | border: "1px solid #333", 10 | borderRadius: "2px", 11 | marginRight: "8px", 12 | minWidth: "18px", 13 | }, 14 | } 15 | 16 | class DownloadForm extends React.Component { 17 | static downloadBlob(blob, fileName) { 18 | if (window.navigator.msSaveOrOpenBlob) { 19 | // edge 20 | window.navigator.msSaveBlob(blob, fileName) 21 | } else { 22 | // chrome, ff, safari 23 | const elem = window.document.createElement("a") 24 | elem.href = window.URL.createObjectURL(blob) 25 | elem.download = fileName 26 | document.body.appendChild(elem) 27 | elem.click() 28 | document.body.removeChild(elem) 29 | } 30 | } 31 | 32 | constructor(props) { 33 | super(props) 34 | this.downloadImage = this.downloadImage.bind(this) 35 | this.downloadAnnotations = this.downloadAnnotations.bind(this) 36 | } 37 | 38 | downloadAnnotations() { 39 | let blobContent 40 | 41 | const addImageSize = annotationList => 42 | annotationList.map(annotation => { 43 | annotation.imageSize = [this.props.image.width, this.props.image.height] 44 | return annotation 45 | }) 46 | 47 | if (this.props.image.numFrames != null) { 48 | // frame set 49 | const imageMap = {} 50 | // create lookup map for original file names 51 | if (this.props.image.originalFileNames != null) { 52 | Object.keys(this.props.image.originalFileNames).forEach(index => { 53 | imageMap[index] = this.props.image.originalFileNames[index] 54 | }) 55 | } 56 | // compile annotation result 57 | const result = {} 58 | Object.values(this.props.imageAnnotations).forEach(annotation => { 59 | const key = `${annotation.frameNum}` 60 | result[imageMap[key] || key] = addImageSize(annotation.shapes) 61 | }) 62 | blobContent = [JSON.stringify(result)] 63 | } else { 64 | blobContent = [JSON.stringify(addImageSize(this.props.annotations))] 65 | } 66 | 67 | // download just annotations for current image 68 | const blob = new Blob(blobContent, { type: "application/json" }) 69 | const hasLabel = this.props.image.label != null && this.props.image.label.trim() !== "" 70 | const fileName = `${hasLabel ? this.props.image.label : this.props.image._id}.json` 71 | DownloadForm.downloadBlob(blob, fileName) 72 | } 73 | 74 | downloadImage() { 75 | const extension = this.props.image.contentType.split("/")[1] 76 | const hasLabel = this.props.image.label != null && this.props.image.label.trim() !== "" 77 | const fileName = `${hasLabel ? this.props.image.label : this.props.image._id}.${extension}` 78 | const url = `/image/${this.props.image.projectId}/${this.props.image._id}` 79 | fetch(url) 80 | .then(response => response.blob()) 81 | .then(blob => { 82 | DownloadForm.downloadBlob(blob, fileName) 83 | }) 84 | } 85 | 86 | render() { 87 | return ( 88 |
89 | 95 | 101 |
102 | ) 103 | } 104 | } 105 | 106 | export default DownloadForm 107 | -------------------------------------------------------------------------------- /frontend/src/components/forms/ImageUploadForm.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Alert from "react-s-alert" 3 | import { connect } from "react-redux" 4 | import { Popup, mixins } from "quick-n-dirty-react" 5 | import { uploadImage } from "../../redux/images" 6 | 7 | const style = { 8 | fileUpload: { 9 | display: "block", 10 | width: "300px", 11 | }, 12 | } 13 | 14 | @connect(() => ({})) 15 | class ImageUploadForm extends React.Component { 16 | constructor(props) { 17 | super(props) 18 | this.state = { 19 | showPopup: false, 20 | currentFileName: "", 21 | } 22 | this.uploadFile = null 23 | this.changeCurrentFileName = this.changeCurrentFileName.bind(this) 24 | this.togglePopup = this.togglePopup.bind(this) 25 | this.triggerUpload = this.triggerUpload.bind(this) 26 | } 27 | 28 | changeCurrentFileName(ev) { 29 | this.setState({ 30 | currentFileName: ev.target.value, 31 | }) 32 | } 33 | 34 | togglePopup() { 35 | let currentFileName = "" 36 | if (this.uploadFile.files != null && this.uploadFile.files.length > 0) { 37 | currentFileName = this.uploadFile.files[0].name 38 | } 39 | this.setState(oldState => ({ 40 | ...oldState, 41 | showPopup: !oldState.showPopup, 42 | currentFileName, 43 | })) 44 | } 45 | 46 | triggerUpload() { 47 | const file = this.uploadFile.files[0] 48 | if ( 49 | file.type.startsWith("image/") || 50 | file.type === "application/zip" || 51 | file.type === "application/x-zip-compressed" 52 | ) { 53 | // valid image, upload file 54 | const formData = new FormData() 55 | formData.append("file", file) 56 | formData.append("label", this.state.currentFileName) 57 | this.props.dispatch(uploadImage(this.props.projectId, formData)) 58 | } else { 59 | console.error(`'${file.type}' is not a valid file type`) 60 | Alert.warning("Can only upload images or zip archives.") 61 | } 62 | // reset form 63 | this.uploadFile.value = "" 64 | // reset state 65 | this.setState({ 66 | showPopup: false, 67 | currentFileName: "", 68 | }) 69 | } 70 | 71 | render() { 72 | return ( 73 |
74 | 77 | { 82 | this.uploadFile = el 83 | }} 84 | style={style.fileUpload} 85 | /> 86 | {this.state.showPopup ? ( 87 | 88 | 91 | 98 | 99 | ) : null} 100 |
101 | ) 102 | } 103 | } 104 | 105 | export default ImageUploadForm 106 | -------------------------------------------------------------------------------- /frontend/src/components/forms/SettingsPopup.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Popup, mixins } from "quick-n-dirty-react" 3 | 4 | const style = { 5 | slider: { 6 | marginTop: "3px", 7 | width: "140px", 8 | height: "20px", 9 | }, 10 | sliderLegend: { 11 | width: "140px", 12 | display: "grid", 13 | gridTemplateColumns: "1fr 1fr", 14 | fontStyle: "italic", 15 | fontSize: "11px", 16 | }, 17 | grid: { 18 | display: "grid", 19 | gridTemplateColumns: "repeat(2, 1fr)", 20 | gridColumnGap: "20px", 21 | }, 22 | label: { 23 | ...mixins.label, 24 | textDecoration: "none", 25 | marginTop: "0px", 26 | }, 27 | transparencyNumber: { 28 | cursor: "forbidden", 29 | width: "50px", 30 | display: "inline-block", 31 | textAlign: "center", 32 | marginLeft: "15px", 33 | }, 34 | checkboxGrid: { 35 | display: "grid", 36 | gridTemplateColumns: "30px 1fr", 37 | gridRowGap: "10px", 38 | }, 39 | } 40 | 41 | class SettingsPopup extends React.Component { 42 | constructor(props) { 43 | super(props) 44 | 45 | this.state = { 46 | transparency: 0.5, 47 | } 48 | 49 | this.save = this.save.bind(this) 50 | this.changeTransparency = this.changeTransparency.bind(this) 51 | } 52 | 53 | componentDidMount() { 54 | this.setState({ 55 | transparency: 1.0 - this.props.settings.ANTR_TRANSPARENCY, 56 | }) 57 | } 58 | 59 | changeTransparency(ev) { 60 | const value = parseFloat(ev.target.value) 61 | this.setState({ 62 | transparency: value, 63 | }) 64 | } 65 | 66 | save() { 67 | const settings = { 68 | ANTR_SAVE_FRAME_NEXT: this.saveNextFrame.checked, 69 | ANTR_OUTLINE_ONLY: this.outlineOnly.checked, 70 | ANTR_TRANSPARENCY: 1.0 - this.state.transparency, 71 | } 72 | this.props.saveSettings(settings) 73 | } 74 | 75 | render() { 76 | return ( 77 | 78 |
79 | {/* transparency */} 80 |
81 |
82 | 83 |
84 |
85 |
86 | { 88 | this.transparency = el 89 | }} 90 | type="range" 91 | min="0" 92 | max="1" 93 | step={0.1} 94 | value={this.state.transparency} 95 | style={style.slider} 96 | onChange={this.changeTransparency} 97 | /> 98 |
99 |
100 | 106 |
107 |
108 |
109 |
solid
110 |
invisible
111 |
112 |
113 | {/* outline */} 114 |
115 |
116 | { 121 | this.outlineOnly = el 122 | }} 123 | /> 124 |
125 |
126 | 129 |
130 |
131 | { 136 | this.saveNextFrame = el 137 | }} 138 | /> 139 |
140 |
141 | 144 |
145 |
146 | {/* save on frame nav */} 147 |
148 |
149 | ) 150 | } 151 | } 152 | 153 | export default SettingsPopup 154 | -------------------------------------------------------------------------------- /frontend/src/components/images/FrameControl.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 3 | import { mixins } from "quick-n-dirty-react" 4 | 5 | const style = { 6 | icon(desiredFrame, numFrames) { 7 | const baseStyle = { 8 | padding: "5px", 9 | border: "1px solid #333", 10 | borderRadius: "2px", 11 | marginRight: "8px", 12 | minWidth: "18px", 13 | } 14 | if (desiredFrame < 0 || desiredFrame === numFrames) { 15 | return { 16 | ...baseStyle, 17 | cursor: "not-allowed", 18 | color: "#aaa", 19 | } 20 | } 21 | return { 22 | ...baseStyle, 23 | ...mixins.clickable, 24 | } 25 | }, 26 | frameIndicator: { 27 | ...mixins.smallFont, 28 | width: "50px", 29 | textAlign: "center", 30 | marginLeft: "-8px", 31 | paddingTop: "5px", 32 | }, 33 | overlapIcon(desiredFrame, numFrames) { 34 | const baseStyle = { 35 | marginLeft: "-1px", 36 | } 37 | if (desiredFrame < 0 || desiredFrame === numFrames) { 38 | return { 39 | ...baseStyle, 40 | color: "#aaa", 41 | } 42 | } 43 | return baseStyle 44 | }, 45 | overlap: { 46 | position: "absolute", 47 | top: "6px", 48 | left: "14px", 49 | }, 50 | } 51 | 52 | const FrameControl = ({ image, currentFrame, switchFrame }) => { 53 | const goBack = () => { 54 | if (currentFrame === 0) { 55 | return 56 | } 57 | switchFrame(currentFrame - 1) 58 | } 59 | const goForward = () => { 60 | if (currentFrame === image.numFrames - 1) { 61 | return 62 | } 63 | switchFrame(currentFrame + 1) 64 | } 65 | const goStart = () => { 66 | switchFrame(0) 67 | } 68 | const goEnd = () => { 69 | switchFrame(image.numFrames - 1) 70 | } 71 | 72 | return ( 73 |
74 |
75 |
76 | 77 |
78 | 79 |
80 |
81 |
82 |
83 | 88 |
89 |
90 | {currentFrame + 1} / {image.numFrames} 91 |
92 |
93 | 98 |
99 |
100 |
101 | 105 |
106 | 107 |
108 |
109 |
110 |
111 | ) 112 | } 113 | 114 | export default FrameControl 115 | -------------------------------------------------------------------------------- /frontend/src/components/images/FramePlayer.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 3 | import mixins from "../../mixins" 4 | import util from "../../util" 5 | import common from "../../tools/common" 6 | 7 | const SPEED = { 8 | max: 1500, 9 | min: 100, 10 | } 11 | const DEFAULT_WIDTH = [250, 500, 750] 12 | 13 | const style = { 14 | image(current, currentFrame, start, markFrames) { 15 | if (current === currentFrame && markFrames) { 16 | return { 17 | border: "2px solid #f00", 18 | } 19 | } 20 | if (current === start && markFrames) { 21 | return { 22 | border: "2px solid #0f0", 23 | } 24 | } 25 | return { 26 | border: "2px solid #f3f3f3", 27 | } 28 | }, 29 | controls: { 30 | padding: "10px", 31 | }, 32 | controlOptions: { 33 | paddingTop: "10px", 34 | display: "grid", 35 | gridTemplateColumns: "120px 120px", 36 | gridColumnGap: "5px", 37 | }, 38 | toolBoxHeader: { 39 | fontSize: "10px", 40 | fontWeight: "600", 41 | textDecoration: "underline", 42 | paddingBottom: "4px", 43 | }, 44 | expand: { 45 | ...mixins.icon, 46 | background: "rgba(200, 200, 200, 0.5)", 47 | position: "absolute", 48 | top: "5px", 49 | left: "5px", 50 | }, 51 | } 52 | 53 | class FramePlayer extends React.Component { 54 | constructor(props) { 55 | super(props) 56 | this.state = { 57 | speed: util.getLocalStorageNumber("defaultSpeed", 500), 58 | frames: util.getLocalStorageNumber("defaultFrames", 5), 59 | windowWidth: DEFAULT_WIDTH[0], 60 | frameNumbers: [], 61 | current: null, 62 | start: null, 63 | end: null, 64 | playing: false, 65 | markFrames: true, 66 | } 67 | 68 | this.changeSpeed = this.changeSpeed.bind(this) 69 | this.changeFrames = this.changeFrames.bind(this) 70 | this.changeWindowWidth = this.changeWindowWidth.bind(this) 71 | this.pause = this.pause.bind(this) 72 | this.resume = this.resume.bind(this) 73 | this.toggleMarkFrames = this.toggleMarkFrames.bind(this) 74 | this.mouseMove = this.mouseMove.bind(this) 75 | this.timer = null 76 | } 77 | 78 | componentDidMount() { 79 | this.setFrameNumbers() 80 | } 81 | 82 | componentDidUpdate(prevProps, prevState, snapshot) { 83 | if (prevProps.currentFrame !== this.props.currentFrame || prevState.frames !== this.state.frames) { 84 | this.setFrameNumbers() 85 | } 86 | } 87 | 88 | componentWillUnmount() { 89 | clearInterval(this.timer) 90 | } 91 | 92 | setFrameNumbers() { 93 | this.pause() 94 | const { image, currentFrame } = this.props 95 | const { frames } = this.state 96 | 97 | const result = [] 98 | const start = Math.max(0, currentFrame - frames) 99 | const end = Math.min(image.numFrames - 1, currentFrame + frames) 100 | 101 | for (let i = start; i <= end; i += 1) { 102 | result.push(i) 103 | } 104 | this.setState({ 105 | frameNumbers: result, 106 | current: start, 107 | start, 108 | end, 109 | }) 110 | this.resume() 111 | } 112 | 113 | pause() { 114 | if (this.timer != null) { 115 | clearInterval(this.timer) 116 | this.setState({ 117 | playing: false, 118 | }) 119 | } 120 | } 121 | 122 | resume() { 123 | this.timer = setInterval(() => { 124 | this.setState(oldState => { 125 | let newValue = oldState.current + 1 126 | if (oldState.current === oldState.end) { 127 | newValue = oldState.start 128 | } 129 | return { 130 | ...oldState, 131 | current: newValue, 132 | } 133 | }) 134 | }, this.state.speed) 135 | this.setState({ 136 | playing: true, 137 | }) 138 | } 139 | 140 | toggleMarkFrames() { 141 | this.setState(oldState => ({ 142 | ...oldState, 143 | markFrames: !oldState.markFrames, 144 | })) 145 | } 146 | 147 | changeSpeed(ev) { 148 | const newSpeed = SPEED.max + SPEED.min - parseInt(ev.target.value, 10) 149 | this.setState({ speed: newSpeed }) 150 | this.pause() 151 | this.resume() 152 | localStorage.setItem("defaultSpeed", newSpeed) 153 | } 154 | 155 | changeFrames(ev) { 156 | const frames = parseInt(ev.target.value, 10) 157 | this.setState({ frames }) 158 | localStorage.setItem("defaultFrames", frames) 159 | } 160 | 161 | changeWindowWidth() { 162 | this.setState(oldState => { 163 | let newIndex = DEFAULT_WIDTH.indexOf(oldState.windowWidth) + 1 164 | if (newIndex === DEFAULT_WIDTH.length) { 165 | newIndex = 0 166 | } 167 | return { 168 | ...oldState, 169 | windowWidth: DEFAULT_WIDTH[newIndex], 170 | } 171 | }) 172 | } 173 | 174 | mouseMove(ev) { 175 | const { image } = this.props 176 | const { windowWidth } = this.state 177 | const zoom = windowWidth / image.width 178 | const mousePos = common.getMousePos(this.image, ev, zoom) 179 | this.props.redraw(mousePos) 180 | } 181 | 182 | render() { 183 | const { image, currentFrame } = this.props 184 | const { current, windowWidth, start, frames, speed, playing, markFrames } = this.state 185 | 186 | if (current == null) { 187 | return null 188 | } 189 | 190 | return ( 191 |
192 |
193 | 194 |
195 | { 201 | this.props.redraw() 202 | }} 203 | ref={el => { 204 | this.image = el 205 | }} 206 | /> 207 |
208 |
209 | {/* pause/resume button and player progress indicator */} 210 |
211 | {playing ? : } 212 |
213 |
214 | {this.state.current} 215 |
216 |
217 |
218 | {/* controls for speed and num frames */} 219 |
Prev/Next Frames
220 |
Speed
221 |
222 | 231 |
232 |
233 | 241 |
242 |
243 |
244 |
245 | ) 246 | } 247 | } 248 | 249 | export default FramePlayer 250 | -------------------------------------------------------------------------------- /frontend/src/components/images/ImageList.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 3 | import { connect } from "react-redux" 4 | import { Link } from "react-router-dom" 5 | import mixins from "../../mixins" 6 | import Popup from "../Popup" 7 | import { deleteImage } from "../../redux/images" 8 | import util from "../../util" 9 | 10 | const TILE_WIDTH = 200 - 22 11 | 12 | const style = { 13 | imageList: { 14 | display: "flex", 15 | flexDirection: "row", 16 | flexWrap: "wrap", 17 | alignItems: "center", 18 | alignContent: "center", 19 | }, 20 | imageTile: { 21 | ...mixins.relative, 22 | border: "1px solid #ccc", 23 | padding: "10px", 24 | width: `${TILE_WIDTH}px`, 25 | height: `${TILE_WIDTH}px`, 26 | textAlign: "center", 27 | display: "flex", 28 | alignItems: "center", 29 | }, 30 | imageLine: { 31 | width: "100%", 32 | }, 33 | image: { 34 | maxWidth: `${TILE_WIDTH}px`, 35 | maxHeight: `${TILE_WIDTH}px`, 36 | }, 37 | annotationCount: { 38 | position: "absolute", 39 | padding: "2px 5px", 40 | border: "1px solid #ddd", 41 | background: "#ddd", 42 | borderRadius: "5px", 43 | top: "10px", 44 | right: "14px", 45 | fontSize: "13px", 46 | fontWeight: "600", 47 | }, 48 | deleteLayer: { 49 | ...mixins.clickable, 50 | padding: "10px", 51 | position: "absolute", 52 | bottom: "4px", 53 | right: "4px", 54 | }, 55 | imageLabel: { 56 | position: "absolute", 57 | left: "15px", 58 | bottom: "18px", 59 | padding: "4px", 60 | background: "#ddd", 61 | width: "125px", 62 | borderRadius: "5px", 63 | cursor: "normal", 64 | whiteSpace: "nowrap", 65 | overflow: "hidden", 66 | textOverflow: "ellipsis", 67 | fontSize: "13px", 68 | }, 69 | triangleAnnotation: { 70 | position: "absolute", 71 | top: "0", 72 | left: "0", 73 | width: "0", 74 | height: "0", 75 | borderTop: `50px solid ${mixins.mainColor.color}`, 76 | borderRight: "50px solid transparent", 77 | }, 78 | triangleText: { 79 | ...mixins.smallFont, 80 | paddingLeft: "5px", 81 | marginTop: "-45px", 82 | color: "#fff", 83 | }, 84 | } 85 | 86 | @connect(() => ({})) 87 | class ImageList extends React.Component { 88 | constructor(props) { 89 | super(props) 90 | this.state = { 91 | showDelete: false, 92 | deleteImageId: null, 93 | } 94 | 95 | this.toggleDelete = this.toggleDelete.bind(this) 96 | this.deleteImage = this.deleteImage.bind(this) 97 | } 98 | 99 | toggleDelete(imageId) { 100 | return event => { 101 | if (event != null) { 102 | event.preventDefault() 103 | } 104 | this.setState(oldState => ({ 105 | ...oldState, 106 | showDelete: !oldState.showDelete, 107 | deleteImageId: imageId, 108 | })) 109 | } 110 | } 111 | 112 | deleteImage() { 113 | this.props.dispatch(deleteImage(this.props.project._id, this.state.deleteImageId)) 114 | this.toggleDelete(null)() 115 | } 116 | 117 | render() { 118 | const getAnnotationCount = annotations => { 119 | if (annotations.shapes != null) { 120 | return annotations.shapes.length 121 | } 122 | let total = 0 123 | Object.values(annotations).forEach(annotation => { 124 | total += annotation.shapes.length 125 | }) 126 | return total 127 | } 128 | return ( 129 |
130 | {this.props.images.map(imageMeta => ( 131 | 132 |
133 |
134 | 135 |
136 |
137 | {this.props.annotations[imageMeta._id] != null 138 | ? getAnnotationCount(this.props.annotations[imageMeta._id]) 139 | : "-"} 140 |
141 | {imageMeta.label != null && imageMeta.label.trim() !== "" ? ( 142 |
150 | {this.props.showFilenames && imageMeta.numFrames == null 151 | ? imageMeta.originalFileNames 152 | : imageMeta.label} 153 |
154 | ) : null} 155 |
156 | 157 |
158 | {imageMeta.numFrames != null ? ( 159 |
160 |
{imageMeta.numFrames}
161 |
162 | ) : null} 163 |
164 | 165 | ))} 166 | {this.state.showDelete ? ( 167 | 173 |

Are you sure you want to delete this image?

174 |

175 | 176 | This will delete all associated annotations and predictions, but not the models that 177 | potentially trained on this image. 178 | 179 |

180 |
181 | ) : null} 182 |
183 | ) 184 | } 185 | } 186 | 187 | export default ImageList 188 | -------------------------------------------------------------------------------- /frontend/src/components/project/AnnotationTypeList.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { connect } from "react-redux" 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import { mixins } from "quick-n-dirty-react" 5 | import Alert from "react-s-alert" 6 | import { updateAnnotationTypes } from "../../redux/projects" 7 | 8 | const colorStyle = code => ({ 9 | display: "inline-block", 10 | width: "15px", 11 | height: "15px", 12 | background: code, 13 | border: "1px solid #aaa", 14 | marginLeft: "3px", 15 | }) 16 | 17 | const brightColors = ["#fff", "#0f0", "#ff0", "#0ff"] 18 | const colors = ["#00f", "#0f0", "#fff", "#000", "#ff0", "#0ff", "#f0f"] 19 | 20 | const style = { 21 | table: { 22 | display: "grid", 23 | gridTemplateColumns: "60px 200px 200px", 24 | gridRowGap: "8px", 25 | paddingLeft: "15px", 26 | }, 27 | color: code => colorStyle(code), 28 | newColor: code => ({ 29 | ...colorStyle(code), 30 | ...mixins.clickable, 31 | }), 32 | deleteIcon: { 33 | ...mixins.clickable, 34 | ...mixins.red, 35 | }, 36 | addIcon: { 37 | ...mixins.clickable, 38 | ...mixins.green, 39 | }, 40 | listHeader: { 41 | ...mixins.smallFont, 42 | fontWeight: "600", 43 | borderBottom: "1px solid #999", 44 | }, 45 | formLineElement: { 46 | paddingTop: "15px", 47 | }, 48 | check: backgroundColor => ({ 49 | fontSize: "18px", 50 | color: brightColors.indexOf(backgroundColor) !== -1 ? "#000" : "#fff", 51 | paddingLeft: "2px", 52 | display: "inline-block", 53 | }), 54 | } 55 | 56 | @connect(() => ({})) 57 | class AnnotationTypeList extends React.Component { 58 | constructor(props) { 59 | super(props) 60 | this.state = { 61 | annotationTypes: {}, 62 | currentColor: null, 63 | hasChanged: false, 64 | } 65 | this.saveTypes = this.saveTypes.bind(this) 66 | this.initState = this.initState.bind(this) 67 | this.addType = this.addType.bind(this) 68 | this.removeType = this.removeType.bind(this) 69 | } 70 | 71 | componentDidMount() { 72 | this.initState(this.props.annotationTypes) 73 | } 74 | 75 | componentDidUpdate(prevProps, prevState, snapshot) { 76 | if (JSON.stringify(prevProps.annotationTypes) !== JSON.stringify(this.props.annotationTypes)) { 77 | this.initState(this.props.annotationTypes) 78 | } 79 | } 80 | 81 | initState(annotationTypes) { 82 | this.setState({ 83 | annotationTypes: annotationTypes || {}, 84 | }) 85 | } 86 | 87 | addType() { 88 | if (this.newId.value.trim() === "" || this.state.currentColor == null) { 89 | Alert.warning("Please provide an identifier and select a colour") 90 | return 91 | } 92 | this.setState(oldState => { 93 | const annotationTypes = { ...oldState.annotationTypes } 94 | annotationTypes[this.newId.value] = oldState.currentColor 95 | this.newId.value = "" 96 | return { 97 | ...oldState, 98 | annotationTypes, 99 | currentColor: null, 100 | hasChanged: true, 101 | } 102 | }) 103 | } 104 | 105 | changeColor(colorCode) { 106 | return () => { 107 | this.setState({ 108 | currentColor: colorCode, 109 | }) 110 | } 111 | } 112 | 113 | removeType(typeId) { 114 | return () => { 115 | this.setState(oldState => { 116 | const annotationTypes = { ...oldState.annotationTypes } 117 | delete annotationTypes[typeId] 118 | return { 119 | ...oldState, 120 | annotationTypes, 121 | hasChanged: true, 122 | } 123 | }) 124 | } 125 | } 126 | 127 | saveTypes() { 128 | this.props.dispatch(updateAnnotationTypes(this.props.projectId, this.state.annotationTypes)) 129 | // removes the save button to avoid double clicking 130 | this.setState({ 131 | hasChanged: false, 132 | }) 133 | } 134 | 135 | render() { 136 | return ( 137 |
138 |
139 |
140 |
Identifier
141 |
Colour
142 | {Object.keys(this.state.annotationTypes).map(aType => [ 143 |
144 | 145 |
, 146 |
{aType}
, 147 |
148 | 149 |
, 150 | ])} 151 |
152 | 153 |
154 |
155 | { 159 | this.newId = el 160 | }} 161 | /> 162 |
163 |
164 | {colors 165 | .filter(color => Object.values(this.state.annotationTypes).indexOf(color) === -1) 166 | .map(colorCode => ( 167 |
172 | {this.state.currentColor === colorCode ? ( 173 | × 174 | ) : null} 175 |
176 | ))} 177 |
178 |
179 |
180 | {this.state.hasChanged === true ? ( 181 | 184 | ) : null} 185 |
186 |
187 | ) 188 | } 189 | } 190 | 191 | export default AnnotationTypeList 192 | -------------------------------------------------------------------------------- /frontend/src/components/project/AnnotationTypeMigration.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { mixins } from "quick-n-dirty-react" 3 | 4 | const style = { 5 | table: { 6 | display: "grid", 7 | gridColumnGap: "3px", 8 | gridTemplateColumns: "1fr 1fr 1fr 1fr", 9 | }, 10 | colorStyle: code => ({ 11 | display: "inline-block", 12 | width: "15px", 13 | height: "15px", 14 | background: code, 15 | border: "1px solid #aaa", 16 | marginLeft: "3px", 17 | }), 18 | header: { 19 | ...mixins.smallFont, 20 | color: "#666", 21 | fontWeight: 600, 22 | borderBottom: "1px solid #999", 23 | marginBottom: "5px", 24 | }, 25 | dropDown: { 26 | ...mixins.textInput, 27 | padding: "2px 6px", 28 | }, 29 | } 30 | 31 | class AnnotationTypeMigration extends React.Component { 32 | constructor(props) { 33 | super(props) 34 | this.state = { 35 | currentMapping: {}, 36 | } 37 | 38 | this.changeReplace = this.changeReplace.bind(this) 39 | this.changeKeepDelete = this.changeKeepDelete.bind(this) 40 | this.updateMapping = this.updateMapping.bind(this) 41 | 42 | this.dropDowns = {} 43 | this.radioDelete = {} 44 | this.radioKeep = {} 45 | } 46 | 47 | componentDidMount() { 48 | const currentMapping = {} 49 | Object.keys(this.props.old).forEach(existingType => { 50 | currentMapping[existingType] = -1 // default is to delete (-1) 51 | }) 52 | this.setState({ 53 | currentMapping, 54 | }) 55 | this.props.setMapping(currentMapping) 56 | } 57 | 58 | changeReplace(oldType) { 59 | return ev => { 60 | const selected = ev.target.value 61 | this.updateMapping(oldType, selected) 62 | this.radioKeep[oldType].checked = false 63 | this.radioDelete[oldType].checked = false 64 | } 65 | } 66 | 67 | changeKeepDelete(oldType) { 68 | return ev => { 69 | const selected = parseInt(ev.target.value, 10) 70 | this.updateMapping(oldType, selected) 71 | this.dropDowns[oldType].value = "" 72 | } 73 | } 74 | 75 | updateMapping(oldType, newValue) { 76 | this.setState(oldState => { 77 | const { currentMapping } = oldState 78 | currentMapping[oldType] = newValue 79 | this.props.setMapping(currentMapping) 80 | return { 81 | ...oldState, 82 | currentMapping, 83 | } 84 | }) 85 | } 86 | 87 | render() { 88 | return ( 89 |
90 |
Old Type
91 |
Replace With
92 |
Remove
93 |
Keep
94 | {Object.keys(this.state.currentMapping).map(oldType => [ 95 |
96 | {oldType} 97 | 98 |
, 99 |
100 | 114 |
, 115 |
116 | { 123 | this.radioDelete[oldType] = el 124 | }} 125 | /> 126 |
, 127 |
128 | { 134 | this.radioKeep[oldType] = el 135 | }} 136 | /> 137 |
, 138 | ])} 139 |
140 | ) 141 | } 142 | } 143 | 144 | export default AnnotationTypeMigration 145 | -------------------------------------------------------------------------------- /frontend/src/components/project/ProjectSettingsImpex.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { connect } from "react-redux" 3 | import { mixins, Popup } from "quick-n-dirty-react" 4 | import Alert from "react-s-alert" 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 6 | import DownloadForm from "../forms/DownloadForm" 7 | import AnnotationTypeMigration from "./AnnotationTypeMigration" 8 | import { importProjectSettings } from "../../redux/projects" 9 | 10 | const style = { 11 | exportLink: { 12 | ...mixins.textLink, 13 | display: "inline-block", 14 | }, 15 | iconLabel: { 16 | paddingLeft: "10px", 17 | }, 18 | } 19 | 20 | // not 100% ideal, but does the job 21 | const urlFriendly = string => 22 | string 23 | .replace(/\./, "") 24 | .replace(/\//, "") 25 | .replace("/\\/", "") 26 | .replace(/ /, "") 27 | .replace(/#/, "") 28 | 29 | @connect(store => ({})) 30 | class ProjectSettingsImpex extends React.Component { 31 | constructor(props) { 32 | super(props) 33 | 34 | this.state = { 35 | showImportDialog: false, 36 | currentImport: null, 37 | annotationTypeMapping: null, 38 | } 39 | 40 | this.exportSettings = this.exportSettings.bind(this) 41 | this.prepareImportSettings = this.prepareImportSettings.bind(this) 42 | this.importSettings = this.importSettings.bind(this) 43 | this.cancelImport = this.cancelImport.bind(this) 44 | this.changeAnnotationTypeMapping = this.changeAnnotationTypeMapping.bind(this) 45 | } 46 | 47 | changeAnnotationTypeMapping(newMapping) { 48 | this.setState({ 49 | annotationTypeMapping: newMapping, 50 | }) 51 | } 52 | 53 | exportSettings() { 54 | const { annotationTypes } = this.props.project 55 | const exportJson = { 56 | annotationTypes, 57 | } 58 | const blobContent = [JSON.stringify(exportJson)] 59 | const blob = new Blob(blobContent, { type: "application/json" }) 60 | const fileName = `annotator-settings-${urlFriendly(this.props.project.name)}.json` 61 | DownloadForm.downloadBlob(blob, fileName) 62 | } 63 | 64 | importSettings() { 65 | this.props.dispatch( 66 | importProjectSettings(this.props.project._id, this.state.currentImport, { 67 | annotationTypes: this.state.annotationTypeMapping, 68 | }) 69 | ) 70 | this.cancelImport() 71 | } 72 | 73 | cancelImport() { 74 | this.setState({ 75 | showImportDialog: false, 76 | currentImport: false, 77 | annotationTypeMapping: null, 78 | }) 79 | this.fileInput.value = "" 80 | } 81 | 82 | prepareImportSettings(ev) { 83 | // read the file content 84 | const file = ev.target.files[0] 85 | const reader = new FileReader() 86 | reader.onload = (() => loadEvent => { 87 | try { 88 | // parse the JSON of the file 89 | const jsonContent = JSON.parse(loadEvent.target.result) 90 | 91 | // update component (TODO: validate json) 92 | this.setState({ 93 | currentImport: jsonContent, 94 | showImportDialog: true, 95 | }) 96 | } catch (e) { 97 | Alert.error("Provided JSON file doesn't contain valid JSON") 98 | } 99 | })(file) 100 | reader.readAsText(file) 101 | } 102 | 103 | render() { 104 | return ( 105 |
106 |
107 | 108 | Export Project Settings 109 |
110 |
111 |
Import Settings
112 | { 117 | this.fileInput = el 118 | }} 119 | /> 120 |
121 | Please provide a JSON file with valid project settings. 122 |
123 | {this.state.showImportDialog ? ( 124 | 125 | {Object.keys(this.props.project.annotationTypes).length === 0 ? ( 126 |

Please press "Ok" to import the settings

127 | ) : ( 128 |
129 |

130 | You have pre-existing annotation types. Please choose what to do with these 131 | annotation types. Note that removing an old type will retain existing annotations, 132 | but un-assign their type. If you decide to keep an annotation type, you may have the 133 | same colour assigned twice, if one of the new types also uses that colour. 134 |

135 | 140 |
141 | )} 142 |
143 | ) : null} 144 |
145 | ) 146 | } 147 | } 148 | 149 | export default ProjectSettingsImpex 150 | -------------------------------------------------------------------------------- /frontend/src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Alert from "react-s-alert" 3 | import { Route, Switch, withRouter } from "react-router" 4 | import { BrowserRouter } from "react-router-dom" 5 | import "react-s-alert/dist/s-alert-default.css" 6 | import ProjectContainer from "./ProjectContainer" 7 | import ProjectOverview from "./ProjectOverview" 8 | import ImageView from "./ImageView" 9 | 10 | const style = { 11 | main: { 12 | padding: "10px", 13 | }, 14 | } 15 | 16 | const InsideApp = withRouter(() => ( 17 |
18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | )) 28 | const App = () => ( 29 | 30 | 31 | 32 | ) 33 | 34 | export default App 35 | -------------------------------------------------------------------------------- /frontend/src/containers/ImageView.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { connect } from "react-redux" 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 4 | import { mixins, Popup, NotificationBar } from "quick-n-dirty-react" 5 | import { getImage, selectImage, updateImageLabel } from "../redux/images" 6 | import { 7 | createAnnotation, 8 | createFrameAnnotation, 9 | getAnnotationForImage, 10 | updateAnnotation, 11 | updateFrameAnnotation, 12 | } from "../redux/annotations" 13 | import ImageAnnotation from "../components/images/ImageAnnotation" 14 | import { getProjects } from "../redux/projects" 15 | import Breadcrumb from "../components/Breadcrumb" 16 | import AnnotationTypeSelector from "../components/forms/AnnotationTypeSelector" 17 | 18 | const style = { 19 | editIcon: { 20 | ...mixins.clickable, 21 | }, 22 | missingLabel: { 23 | ...mixins.clickable, 24 | fontSize: "14px", 25 | fontFamily: '"Courier New", Courier, monospace', 26 | }, 27 | imageFileName: { 28 | ...mixins.trimOverflow, 29 | ...mixins.indent(10), 30 | maxWidth: "calc(100vw - 400px)", 31 | fontSize: "14px", 32 | fontFamily: "Courier", 33 | color: "#666", 34 | }, 35 | copyIcon: {}, 36 | } 37 | 38 | @connect(store => ({ 39 | projects: store.projects.projectList, 40 | currentImage: store.images.currentImage, 41 | images: store.images.imageList, 42 | annotations: store.annotations.annotationList, 43 | })) 44 | class ImageView extends React.Component { 45 | constructor(props) { 46 | super(props) 47 | 48 | this.state = { 49 | currentAnnotationType: null, 50 | editLabel: false, 51 | currentFrame: null, 52 | } 53 | 54 | this.getImageId = this.getImageId.bind(this) 55 | this.getProjectId = this.getProjectId.bind(this) 56 | this.saveAnnotation = this.saveAnnotation.bind(this) 57 | this.changeCurrentAnnotationType = this.changeCurrentAnnotationType.bind(this) 58 | this.updateEditLabel = this.updateEditLabel.bind(this) 59 | this.toggleEditLabel = this.toggleEditLabel.bind(this) 60 | this.setFrame = this.setFrame.bind(this) 61 | this.copyLabel = this.copyLabel.bind(this) 62 | } 63 | 64 | componentDidMount() { 65 | // project 66 | if (this.props.projects[this.getProjectId()] == null) { 67 | this.props.dispatch(getProjects()) 68 | } 69 | // image related 70 | if ( 71 | this.props.images[this.getProjectId()] != null && 72 | this.props.images[this.getProjectId()][this.getImageId()] != null 73 | ) { 74 | this.props.dispatch(selectImage(this.props.images[this.getProjectId()][this.getImageId()])) 75 | } else { 76 | this.props.dispatch(getImage(this.getProjectId(), this.getImageId())) 77 | } 78 | // annotations 79 | this.props.dispatch(getAnnotationForImage(this.getProjectId(), this.getImageId())) 80 | } 81 | 82 | componentDidUpdate(prevProps, prevState, snapshot) { 83 | if ( 84 | this.state.currentFrame == null && 85 | this.props.currentImage != null && 86 | this.props.currentImage.numFrames != null 87 | ) { 88 | this.setFrame(0) 89 | } 90 | } 91 | 92 | getImageId() { 93 | return this.props.match.params.imageId 94 | } 95 | 96 | getProjectId() { 97 | return this.props.match.params.projectId 98 | } 99 | 100 | setFrame(newFrame) { 101 | this.setState({ 102 | currentFrame: newFrame, 103 | }) 104 | } 105 | 106 | changeCurrentAnnotationType(selectedType) { 107 | this.setState({ 108 | currentAnnotationType: selectedType, 109 | }) 110 | } 111 | 112 | saveAnnotation(data, frameNum = null) { 113 | if (frameNum == null) { 114 | // default single image handling 115 | if (this.props.annotations[this.getImageId()] == null) { 116 | // create new annotation 117 | this.props.dispatch( 118 | createAnnotation(this.getProjectId(), this.getImageId(), { 119 | shapes: data, 120 | imageId: this.getImageId(), 121 | projectId: this.getProjectId(), 122 | }) 123 | ) 124 | } else { 125 | // update existing annotation 126 | const existing = this.props.annotations[this.getImageId()] 127 | existing.shapes = data 128 | this.props.dispatch(updateAnnotation(existing.projectId, existing.imageId, existing)) 129 | } 130 | return 131 | } 132 | 133 | // handling for frame 134 | if ( 135 | this.props.annotations[this.getImageId()] == null || 136 | this.props.annotations[this.getImageId()][frameNum] == null 137 | ) { 138 | // create new annotation 139 | this.props.dispatch( 140 | createFrameAnnotation(this.getProjectId(), this.getImageId(), { 141 | shapes: data, 142 | imageId: this.getImageId(), 143 | projectId: this.getProjectId(), 144 | frameNum, 145 | }) 146 | ) 147 | } else { 148 | // update existing annotation 149 | const existing = this.props.annotations[this.getImageId()][frameNum] 150 | existing.shapes = data 151 | this.props.dispatch(updateFrameAnnotation(existing.projectId, existing.imageId, existing)) 152 | } 153 | } 154 | 155 | toggleEditLabel() { 156 | this.setState(oldState => ({ 157 | ...oldState, 158 | editLabel: !oldState.editLabel, 159 | })) 160 | } 161 | 162 | updateEditLabel() { 163 | const newLabel = this.newLabel.value 164 | this.props.dispatch(updateImageLabel(this.getProjectId(), this.props.currentImage._id, newLabel)) 165 | this.setState({ 166 | editLabel: false, 167 | }) 168 | } 169 | 170 | copyLabel(label) { 171 | return () => { 172 | // copy string 173 | const el = document.createElement("textarea") 174 | el.value = label 175 | document.body.appendChild(el) 176 | el.select() 177 | document.execCommand("copy") 178 | document.body.removeChild(el) 179 | this.alert.info("Copied to clipboard") 180 | } 181 | } 182 | 183 | render() { 184 | if (this.props.currentImage == null || this.props.projects[this.getProjectId()] == null) { 185 | return null 186 | } 187 | 188 | const title = 189 | this.props.currentImage.numFrames == null 190 | ? this.props.currentImage.originalFileNames 191 | : this.props.currentImage.originalFileNames[this.state.currentFrame] 192 | 193 | return ( 194 |
195 | { 197 | this.alert = el 198 | }} 199 | position="left" 200 | /> 201 | 202 | 203 | {this.props.currentImage.label} 204 | {this.props.currentImage.label == null ? ( 205 | 206 | [Provide Label] 207 | 208 | ) : null}{" "} 209 | 215 | 216 | 217 | {this.state.editLabel ? ( 218 | 219 | 222 | { 226 | this.newLabel = el 227 | }} 228 | defaultValue={this.props.currentImage.label} 229 | /> 230 | 231 | ) : null} 232 | 233 | 234 | {this.state.currentFrame != null || this.props.currentImage.numFrames == null ? ( 235 |
236 | 242 | 243 | {title} 244 | 245 |
246 | ) : null} 247 | 252 | 260 |
261 | ) 262 | } 263 | } 264 | 265 | export default ImageView 266 | -------------------------------------------------------------------------------- /frontend/src/containers/ProjectContainer.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { connect } from "react-redux" 3 | import { Link } from "react-router-dom" 4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" 5 | import { createProject, deleteProject, getProjects } from "../redux/projects" 6 | import util from "../util" 7 | import mixins from "../mixins" 8 | import Popup from "../components/Popup" 9 | 10 | const TILE_WIDTH = 200 - 32 // 2 for the border, 15 for padding on each side 11 | 12 | const style = { 13 | projectList: { 14 | display: "flex", 15 | flexDirection: "row", 16 | flexWrap: "wrap", 17 | }, 18 | projectTile: { 19 | ...mixins.clickable, 20 | ...mixins.relative, 21 | padding: "15px", 22 | border: "1px solid #ccc", 23 | borderRadius: "5px", 24 | margin: "15px", 25 | width: `${TILE_WIDTH}px`, 26 | height: `${TILE_WIDTH}px`, 27 | display: "flex", 28 | alignContent: "center", 29 | alignItems: "center", 30 | }, 31 | label: { 32 | fontWeight: "600", 33 | fontSize: "20px", 34 | textAlign: "center", 35 | width: `${TILE_WIDTH}px`, 36 | height: "40px", 37 | }, 38 | plusIcon: { 39 | fontSize: "40px", 40 | fontWeight: "100", 41 | color: "#999", 42 | }, 43 | deleteLayer: { 44 | ...mixins.clickable, 45 | padding: "10px", 46 | position: "absolute", 47 | bottom: "0px", 48 | right: "0px", 49 | }, 50 | } 51 | 52 | @connect(store => ({ 53 | projects: store.projects.projectList, 54 | })) 55 | class ProjectContainer extends React.Component { 56 | constructor(props) { 57 | super(props) 58 | this.state = { 59 | showCreate: false, 60 | showDelete: false, 61 | deleteProjectId: null, 62 | } 63 | 64 | this.toggleCreate = this.toggleCreate.bind(this) 65 | this.toggleDelete = this.toggleDelete.bind(this) 66 | this.createProject = this.createProject.bind(this) 67 | this.deleteProject = this.deleteProject.bind(this) 68 | } 69 | 70 | componentDidMount() { 71 | this.props.dispatch(getProjects()) 72 | } 73 | 74 | toggleCreate() { 75 | this.setState(oldState => ({ 76 | ...oldState, 77 | showCreate: !oldState.showCreate, 78 | })) 79 | } 80 | 81 | toggleDelete(id) { 82 | return event => { 83 | event.preventDefault() 84 | this.setState(oldState => ({ 85 | ...oldState, 86 | showDelete: !oldState.showDelete, 87 | deleteProjectId: id, 88 | })) 89 | } 90 | } 91 | 92 | createProject() { 93 | if (this.nameInput == null) { 94 | return 95 | } 96 | const data = { 97 | name: this.nameInput.value, 98 | } 99 | this.props.dispatch(createProject(data)) 100 | this.setState({ 101 | showCreate: false, 102 | }) 103 | } 104 | 105 | deleteProject(id) { 106 | return () => { 107 | this.props.dispatch(deleteProject(id)) 108 | this.setState({ 109 | showDelete: false, 110 | deleteProjectId: null, 111 | }) 112 | } 113 | } 114 | 115 | render() { 116 | return ( 117 |
118 |

Projects

119 |
120 |
121 | 122 | 123 | 124 |
125 | {util.idMapToList(this.props.projects).map(project => ( 126 | 127 |
128 | {project.name} 129 |
130 | 131 |
132 |
133 | 134 | ))} 135 |
136 | {this.state.showCreate ? ( 137 | 138 | 141 | { 145 | this.nameInput = el 146 | }} 147 | style={mixins.textInput} 148 | /> 149 | 150 | ) : null} 151 | {this.state.showDelete && this.state.deleteProjectId != null ? ( 152 | 158 |

Do you really want to delete this project?

159 |

160 | This will delete all associated images, models and annotations. 161 |

162 |
163 | ) : null} 164 |
165 | ) 166 | } 167 | } 168 | 169 | export default ProjectContainer 170 | -------------------------------------------------------------------------------- /frontend/src/containers/ProjectOverview.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { connect } from "react-redux" 3 | import ImageUploadForm from "../components/forms/ImageUploadForm" 4 | import ImageList from "../components/images/ImageList" 5 | import { getProjects } from "../redux/projects" 6 | import { getImages } from "../redux/images" 7 | import mixins from "../mixins" 8 | import util from "../util" 9 | import Breadcrumb from "../components/Breadcrumb" 10 | import { getAnnotationForImage } from "../redux/annotations" 11 | import AnnotationTypeList from "../components/project/AnnotationTypeList" 12 | import ProjectSettingsImpex from "../components/project/ProjectSettingsImpex" 13 | 14 | const style = { 15 | projectSettings: { 16 | display: "grid", 17 | gridTemplateColumns: "500px 500px", 18 | }, 19 | fileNameToggle: { 20 | position: "absolute", 21 | right: "15px", 22 | top: "-10px", 23 | }, 24 | } 25 | 26 | @connect(stores => ({ 27 | projectList: stores.projects.projectList, 28 | imageList: stores.images.imageList, 29 | annotationList: stores.annotations.annotationList, 30 | })) 31 | class ProjectOverview extends React.Component { 32 | constructor(props) { 33 | super(props) 34 | 35 | this.state = { 36 | showFileNames: false, 37 | } 38 | 39 | this.getProjectId = this.getProjectId.bind(this) 40 | this.getProjectImages = this.getProjectImages.bind(this) 41 | this.toggleShowFileNames = this.toggleShowFileNames.bind(this) 42 | } 43 | 44 | componentDidMount() { 45 | if (Object.keys(this.props.projectList).length === 0) { 46 | this.props.dispatch(getProjects()) 47 | } 48 | if (this.getProjectId() != null) { 49 | this.props.dispatch(getImages(this.getProjectId())) 50 | } 51 | } 52 | 53 | componentDidUpdate(prevProps, prevState, snapshot) { 54 | if (this.props.imageList[this.getProjectId()] != null && prevProps.imageList[this.getProjectId()] == null) { 55 | // incoming images 56 | this.getProjectImages().forEach(image => { 57 | this.props.dispatch(getAnnotationForImage(this.getProjectId(), image._id)) 58 | }) 59 | } 60 | } 61 | 62 | getProjectId() { 63 | return this.props.match.params.projectId 64 | } 65 | 66 | getProjectImages() { 67 | const projectId = this.getProjectId() 68 | if (this.props.imageList[projectId] == null) { 69 | return [] 70 | } 71 | return util.idMapToList(this.props.imageList[projectId]) 72 | } 73 | 74 | toggleShowFileNames() { 75 | this.setState(oldState => ({ 76 | ...oldState, 77 | showFileNames: !oldState.showFileNames, 78 | })) 79 | } 80 | 81 | render() { 82 | if (this.getProjectId() == null || this.props.projectList[this.getProjectId()] == null) { 83 | return null 84 | } 85 | 86 | return ( 87 |
88 | 89 |
Upload Image
90 | 91 |
92 |
93 | Images 94 |
95 | 102 | 105 |
106 |
107 |
108 | 114 |
Annotation Types
115 |
116 |
117 | 125 |
126 |
127 | 128 |
129 |
130 |
131 | ) 132 | } 133 | } 134 | 135 | export default ProjectOverview 136 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { library } from "@fortawesome/fontawesome-svg-core" 3 | import { 4 | faTrashAlt, 5 | faEdit, 6 | faCog, 7 | faSyncAlt, 8 | faChevronDown, 9 | faChevronRight, 10 | faChevronLeft, 11 | faDownload, 12 | faCheck, 13 | faTimes, 14 | faPlus, 15 | faEraser, 16 | faDrawPolygon, 17 | faSave, 18 | faFileImport, 19 | faSplotch, 20 | faPen, 21 | faFileImage, 22 | faPause, 23 | faPlay, 24 | faExpand, 25 | faMinus, 26 | faCut, 27 | faFileExport, 28 | faSlash, 29 | faCopy, 30 | } from "@fortawesome/free-solid-svg-icons" 31 | import { render } from "react-dom" 32 | import { createStore, applyMiddleware, compose } from "redux" 33 | import { Provider } from "react-redux" 34 | import promiseMiddleware from "redux-promise-middleware" 35 | import reducer from "./redux/reducers" 36 | import App from "./containers/App" 37 | 38 | // configure fontawesome 39 | const icons = [ 40 | faTrashAlt, 41 | faEdit, 42 | faCog, 43 | faSyncAlt, 44 | faChevronDown, 45 | faChevronRight, 46 | faChevronLeft, 47 | faDownload, 48 | faCheck, 49 | faTimes, 50 | faPlus, 51 | faEraser, 52 | faDrawPolygon, 53 | faSave, 54 | faFileImport, 55 | faSplotch, 56 | faPen, 57 | faFileImage, 58 | faPause, 59 | faPlay, 60 | faExpand, 61 | faMinus, 62 | faCut, 63 | faFileExport, 64 | faSlash, 65 | faCopy, 66 | ] 67 | icons.forEach(icon => { 68 | library.add(icon) 69 | }) 70 | 71 | // create redux store with root reducer and middleware stack 72 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose // eslint-disable-line no-underscore-dangle 73 | const store = createStore(reducer, composeEnhancers(applyMiddleware(promiseMiddleware()))) 74 | 75 | render( 76 | 77 | 78 | , 79 | 80 | document.getElementById("root") 81 | ) 82 | -------------------------------------------------------------------------------- /frontend/src/mixins.js: -------------------------------------------------------------------------------- 1 | import { mixins } from "quick-n-dirty-react" 2 | /** 3 | * Created by Peter Ilfrich 4 | * 5 | * 6 | */ 7 | const mainColor = "#004" 8 | 9 | const styles = { 10 | ...mixins, 11 | mainColor: { 12 | color: mainColor, 13 | }, 14 | right: { 15 | textAlign: "right", 16 | }, 17 | center: { 18 | textAlign: "center", 19 | }, 20 | clickable: { 21 | cursor: "pointer", 22 | }, 23 | white: { 24 | color: "#fff", 25 | }, 26 | red: { 27 | color: "#900", 28 | }, 29 | bold: { 30 | fontWeight: "bold", 31 | }, 32 | backdrop: { 33 | position: "fixed", 34 | top: "0", 35 | left: "0", 36 | background: "rgba(60, 60, 60, 0.3)", 37 | width: "100%", 38 | height: "100%", 39 | }, 40 | clearFix: { 41 | clear: "both", 42 | }, 43 | popup: { 44 | container: { 45 | margin: "auto", 46 | marginTop: "150px", 47 | background: "#eee", 48 | border: "1px solid #eee", 49 | borderRadius: "10px", 50 | position: "relative", 51 | }, 52 | header: { 53 | borderBottom: "1px solid #ccc", 54 | fontSize: "18px", 55 | color: "#aaa", 56 | fontWeight: "bold", 57 | padding: "30px", 58 | }, 59 | body: { 60 | padding: "10px 30px", 61 | }, 62 | footer: { 63 | borderTop: "1px solid #ccc", 64 | textAlign: "right", 65 | padding: "30px", 66 | }, 67 | close: { 68 | position: "absolute", 69 | right: "30px", 70 | top: "10px", 71 | cursor: "pointer", 72 | }, 73 | }, 74 | label: { 75 | display: "inline-block", 76 | maxWidth: "!00%", 77 | fontWeight: "700", 78 | marginTop: "10px", 79 | marginBottom: "5px", 80 | marginLeft: "10px", 81 | fontSize: "14px", 82 | }, 83 | textInput: { 84 | fontSize: "14px", 85 | lineHeight: "1.2", 86 | color: "#555", 87 | backgroundColor: "#fff", 88 | borderLeft: "0px", 89 | borderRight: "0px", 90 | borderTop: "0px", 91 | borderBottom: "1px solid #666666", 92 | borderRadius: "0px", 93 | outline: "none", 94 | display: "block", 95 | width: "calc(100% - 12px)", 96 | height: "31px", 97 | padding: "0px 6px", 98 | }, 99 | button: { 100 | borderRadius: "5px", 101 | padding: "6px 10px", 102 | minWidth: "100px", 103 | borderColor: mainColor, 104 | backgroundColor: mainColor, 105 | fontSize: "14px", 106 | color: "#fff", 107 | cursor: "pointer", 108 | outline: "none", 109 | marginRight: "5px", 110 | }, 111 | inverseButton: { 112 | borderRadius: "5px", 113 | padding: "6px 20px", 114 | minWidth: "120px", 115 | borderColor: "#fff", 116 | color: mainColor, 117 | fontSize: "14px", 118 | backgroundColor: "#eee", 119 | cursor: "pointer", 120 | outline: "none", 121 | marginRight: "5px", 122 | }, 123 | formLine: { 124 | textAlign: "left", 125 | padding: "0px 15px", 126 | }, 127 | card: { 128 | backgroundColor: "#fff", 129 | padding: "10px", 130 | marginTop: "20px", 131 | }, 132 | percentage(base, percent) { 133 | if (Math.isNaN(percent)) { 134 | return { 135 | ...base, 136 | color: "#666", 137 | } 138 | } 139 | if (percent < 20) { 140 | return { 141 | ...base, 142 | color: "#660000", 143 | } 144 | } 145 | if (percent < 40) { 146 | return { 147 | ...base, 148 | color: "#88450a", 149 | } 150 | } 151 | if (percent < 60) { 152 | return { 153 | ...base, 154 | color: "#a18d4b", 155 | } 156 | } 157 | if (percent < 80) { 158 | return { 159 | ...base, 160 | color: "#496613", 161 | } 162 | } 163 | return { 164 | ...base, 165 | color: "#090", 166 | } 167 | }, 168 | panel: { 169 | padding: "30px", 170 | background: "#fff", 171 | color: "#333", 172 | }, 173 | relative: { 174 | position: "relative", 175 | }, 176 | smallFont: { 177 | fontSize: "13px", 178 | }, 179 | noList: { 180 | margin: "0px", 181 | padding: "0px", 182 | listStyle: "none", 183 | }, 184 | noLink: { 185 | color: "#333", 186 | textDecoration: "none", 187 | }, 188 | buttonLine: { 189 | padding: "10px 0px", 190 | }, 191 | vSpacer(px) { 192 | return { 193 | display: "block", 194 | paddingTop: `${px}px`, 195 | } 196 | }, 197 | heading: { 198 | color: "#eee", 199 | border: "1px solid #ccc", 200 | borderRadius: "10px", 201 | padding: "10px 15px", 202 | background: mainColor, 203 | }, 204 | deleteIcon: { 205 | color: "#900", 206 | padding: "5px", 207 | border: "1px solid #ddd", 208 | borderRadius: "15px", 209 | background: "#ddd", 210 | cursor: "pointer", 211 | }, 212 | icon: { 213 | cursor: "pointer", 214 | textAlign: "center", 215 | display: "inline-block", 216 | position: "relative", 217 | padding: "5px", 218 | border: "1px solid #333", 219 | borderRadius: "2px", 220 | marginRight: "8px", 221 | minWidth: "18px", 222 | }, 223 | } 224 | 225 | mixins.buttonDisabled = { 226 | ...mixins.button, 227 | background: "#79818f", 228 | cursor: "not-allowed", 229 | } 230 | mixins.buttonPending = { 231 | ...mixins.button, 232 | background: "#79818f", 233 | cursor: "wait", 234 | } 235 | 236 | export default styles 237 | -------------------------------------------------------------------------------- /frontend/src/redux/annotations.js: -------------------------------------------------------------------------------- 1 | import Alert from "react-s-alert" 2 | import util from "quick-n-dirty-utils" 3 | 4 | const annotationActions = { 5 | GET_ANNOTATION_FOR_IMAGE: "GET_ANNOTATION_FOR_IMAGE", 6 | CREATE_ANNOTATION: "CREATE_ANNOTATION", 7 | UPDATE_ANNOTATION: "UPDATE_ANNOTATION", 8 | } 9 | 10 | export const getAnnotationForImage = (projectId, imageId) => ({ 11 | type: annotationActions.GET_ANNOTATION_FOR_IMAGE, 12 | payload: fetch(`/api/projects/${projectId}/images/${imageId}/annotations`, { 13 | headers: util.getJsonHeader(), 14 | }) 15 | .then(util.restHandler) 16 | .then(annotation => ({ 17 | imageId, 18 | projectId, 19 | annotation: Object.keys(annotation).length === 0 ? null : annotation, 20 | })), 21 | }) 22 | 23 | export const updateAnnotation = (projectId, imageId, annotation) => ({ 24 | type: annotationActions.UPDATE_ANNOTATION, 25 | payload: fetch(`/api/projects/${projectId}/images/${imageId}/annotations/${annotation._id}`, { 26 | method: "POST", 27 | headers: util.getJsonHeader(), 28 | body: JSON.stringify(annotation), 29 | }) 30 | .then(util.restHandler) 31 | .then(response => ({ projectId, imageId, annotation: response })), 32 | }) 33 | 34 | export const updateFrameAnnotation = (projectId, imageId, annotation) => ({ 35 | type: annotationActions.UPDATE_ANNOTATION, 36 | payload: fetch(`/api/projects/${projectId}/images/${imageId}/annotations/${annotation._id}`, { 37 | method: "POST", 38 | headers: util.getJsonHeader(), 39 | body: JSON.stringify(annotation), 40 | }) 41 | .then(util.restHandler) 42 | .then(response => ({ projectId, imageId, annotation: [response] })), 43 | }) 44 | 45 | export const createAnnotation = (projectId, imageId, annotation) => ({ 46 | type: annotationActions.CREATE_ANNOTATION, 47 | payload: fetch(`/api/projects/${projectId}/images/${imageId}/annotations`, { 48 | method: "POST", 49 | headers: util.getJsonHeader(), 50 | body: JSON.stringify(annotation), 51 | }) 52 | .then(util.restHandler) 53 | .then(response => ({ projectId, imageId, annotation: response })), 54 | }) 55 | 56 | export const createFrameAnnotation = (projectId, imageId, annotation) => ({ 57 | type: annotationActions.CREATE_ANNOTATION, 58 | payload: fetch(`/api/projects/${projectId}/images/${imageId}/annotations`, { 59 | method: "POST", 60 | headers: util.getJsonHeader(), 61 | body: JSON.stringify(annotation), 62 | }) 63 | .then(util.restHandler) 64 | .then(response => ({ projectId, imageId, annotation: [response] })), 65 | }) 66 | 67 | const initialState = { 68 | annotationList: {}, 69 | } 70 | 71 | const handleAnnotationRetrieval = (state, action) => { 72 | const annotationList = { ...state.annotationList } 73 | let currentAnnotation = action.payload.annotation 74 | if (currentAnnotation != null && (currentAnnotation.forEach != null || currentAnnotation.frameNum != null)) { 75 | const currentAnnotations = annotationList[action.payload.imageId] || {} 76 | if (currentAnnotation.frameNum != null) { 77 | // single annotation during create 78 | currentAnnotations[currentAnnotation.frameNum] = currentAnnotation 79 | } else { 80 | // process list of annotations 81 | currentAnnotation.forEach(annotation => { 82 | currentAnnotations[annotation.frameNum] = annotation 83 | }) 84 | } 85 | 86 | currentAnnotation = currentAnnotations 87 | } 88 | 89 | annotationList[action.payload.imageId] = currentAnnotation 90 | return { 91 | ...state, 92 | annotationList, 93 | } 94 | } 95 | 96 | export const AnnotationReducer = (state = initialState, action) => { 97 | switch (action.type) { 98 | case `${annotationActions.GET_ANNOTATION_FOR_IMAGE}${util.actionTypeSuffixes.fulfilled}`: { 99 | return handleAnnotationRetrieval(state, action) 100 | } 101 | case `${annotationActions.UPDATE_ANNOTATION}${util.actionTypeSuffixes.fulfilled}`: { 102 | Alert.success("Updated Annotations") 103 | return handleAnnotationRetrieval(state, action) 104 | } 105 | case `${annotationActions.CREATE_ANNOTATION}${util.actionTypeSuffixes.fulfilled}`: { 106 | Alert.success("Created Annotations") 107 | return handleAnnotationRetrieval(state, action) 108 | } 109 | default: 110 | return state 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /frontend/src/redux/images.js: -------------------------------------------------------------------------------- 1 | import Alert from "react-s-alert" 2 | import util from "../util" 3 | 4 | const imageActions = { 5 | UPLOAD_IMAGE: "UPLOAD_IMAGE", 6 | GET_IMAGES: "GET_IMAGES", 7 | GET_IMAGE: "GET_IMAGE", 8 | SELECT_IMAGE: "SELECT_IMAGE", 9 | DELETE_IMAGE: "DELETE_IMAGE", 10 | UPDATE_IMAGE_LABEL: "UPDATE_IMAGE_LABEL", 11 | } 12 | 13 | export const uploadImage = (projectId, formData) => { 14 | const headers = util.getJsonHeader() 15 | delete headers["Content-Type"] 16 | return { 17 | type: imageActions.UPLOAD_IMAGE, 18 | payload: fetch(`/api/projects/${projectId}/images`, { 19 | headers, 20 | method: "POST", 21 | body: formData, 22 | }) 23 | .then(util.restHandler) 24 | .then(imageMeta => ({ 25 | projectId, 26 | image: imageMeta, 27 | })), 28 | } 29 | } 30 | 31 | export const getImages = projectId => ({ 32 | type: imageActions.GET_IMAGES, 33 | payload: fetch(`/api/projects/${projectId}/images`, { 34 | headers: util.getJsonHeader(), 35 | }) 36 | .then(util.restHandler) 37 | .then(images => ({ 38 | projectId, 39 | images, 40 | })), 41 | }) 42 | 43 | export const deleteImage = (projectId, imageId) => ({ 44 | type: imageActions.DELETE_IMAGE, 45 | payload: fetch(`/api/projects/${projectId}/images/${imageId}`, { 46 | method: "DELETE", 47 | headers: util.getJsonHeader(), 48 | }) 49 | .then(util.restHandler) 50 | .then(() => ({ projectId, imageId })), 51 | }) 52 | 53 | export const getImage = (projectId, imageId) => ({ 54 | type: imageActions.GET_IMAGE, 55 | payload: fetch(`/api/projects/${projectId}/images/${imageId}`, { 56 | headers: util.getJsonHeader(), 57 | }).then(util.restHandler), 58 | }) 59 | 60 | export const selectImage = image => ({ 61 | type: imageActions.SELECT_IMAGE, 62 | payload: image, 63 | }) 64 | 65 | export const updateImageLabel = (projectId, imageId, newLabel) => ({ 66 | type: imageActions.UPDATE_IMAGE_LABEL, 67 | payload: fetch(`/api/projects/${projectId}/images/${imageId}`, { 68 | method: "POST", 69 | headers: util.getJsonHeader(), 70 | body: JSON.stringify({ 71 | label: newLabel, 72 | }), 73 | }).then(util.restHandler), 74 | }) 75 | 76 | const initialState = { 77 | imageList: {}, // project ID -> image ID -> image 78 | currentImage: null, 79 | } 80 | 81 | export const ImageReducer = (state = initialState, action) => { 82 | switch (action.type) { 83 | case `${imageActions.UPLOAD_IMAGE}${util.actionTypeSuffixes.fulfilled}`: { 84 | const imageList = { ...state.imageList } 85 | if (imageList[action.payload.projectId] == null) { 86 | imageList[action.payload.projectId] = {} 87 | } 88 | imageList[action.payload.projectId][action.payload.image._id] = action.payload.image 89 | Alert.success("Image uploaded") 90 | return { 91 | ...state, 92 | imageList, 93 | } 94 | } 95 | case `${imageActions.GET_IMAGES}${util.actionTypeSuffixes.fulfilled}`: { 96 | const imageList = { ...state.imageList } 97 | const imageMap = {} 98 | action.payload.images.forEach(image => { 99 | imageMap[image._id] = image 100 | }) 101 | imageList[action.payload.projectId] = imageMap 102 | return { 103 | ...state, 104 | imageList, 105 | } 106 | } 107 | case `${imageActions.DELETE_IMAGE}${util.actionTypeSuffixes.fulfilled}`: { 108 | const imageList = { ...state.imageList } 109 | if (imageList[action.payload.projectId] == null) { 110 | return state 111 | } 112 | delete imageList[action.payload.projectId][action.payload.imageId] 113 | Alert.success("Image deleted") 114 | return { 115 | ...state, 116 | imageList, 117 | } 118 | } 119 | case `${imageActions.GET_IMAGE}${util.actionTypeSuffixes.fulfilled}`: 120 | return { 121 | ...state, 122 | currentImage: action.payload, 123 | } 124 | case imageActions.SELECT_IMAGE: 125 | return { 126 | ...state, 127 | currentImage: action.payload, 128 | } 129 | case `${imageActions.UPDATE_IMAGE_LABEL}${util.actionTypeSuffixes.fulfilled}`: { 130 | const result = { ...state } 131 | const imageList = { ...state.imageList } 132 | if (imageList[action.payload.projectId] != null) { 133 | imageList[action.payload.projectId][action.payload._id] = action.payload 134 | result.imageList = imageList 135 | } 136 | if (state.currentImage != null && state.currentImage._id === action.payload._id) { 137 | result.currentImage = action.payload 138 | } 139 | Alert.success("Label updated") 140 | return result 141 | } 142 | default: 143 | return state 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /frontend/src/redux/projects.js: -------------------------------------------------------------------------------- 1 | import Alert from "react-s-alert" 2 | import util from "../util" 3 | 4 | const projectActions = { 5 | GET_PROJECTS: "GET_PROJECTS", 6 | CREATE_PROJECT: "CREATE_PROJECT", 7 | DELETE_PROJECT: "DELETE_PROJECT", 8 | UPDATE_ANNOTATION_TYPES: "UPDATE_ANNOTATION_TYPES", 9 | IMPORT_PROJECT_SETTINGS: "IMPORT_PROJECT_SETTINGS", 10 | } 11 | 12 | export const getProjects = () => ({ 13 | type: projectActions.GET_PROJECTS, 14 | payload: fetch("/api/projects", { 15 | headers: util.getJsonHeader(), 16 | }).then(util.restHandler), 17 | }) 18 | 19 | export const createProject = projectData => ({ 20 | type: projectActions.CREATE_PROJECT, 21 | payload: fetch("/api/projects", { 22 | method: "POST", 23 | headers: util.getJsonHeader(), 24 | body: JSON.stringify(projectData), 25 | }).then(util.restHandler), 26 | }) 27 | 28 | export const deleteProject = projectId => ({ 29 | type: projectActions.DELETE_PROJECT, 30 | payload: fetch(`/api/projects/${projectId}`, { 31 | method: "DELETE", 32 | }) 33 | .then(util.restHandler) 34 | .then(() => ({ projectId })), 35 | }) 36 | 37 | export const updateAnnotationTypes = (projectId, annotationTypes) => ({ 38 | type: projectActions.UPDATE_ANNOTATION_TYPES, 39 | payload: fetch(`/api/projects/${projectId}/annotation-types`, { 40 | method: "POST", 41 | headers: util.getJsonHeader(), 42 | body: JSON.stringify({ annotationTypes }), 43 | }).then(util.restHandler), 44 | }) 45 | 46 | export const importProjectSettings = (projectId, newSettings, importMapping) => ({ 47 | type: projectActions.IMPORT_PROJECT_SETTINGS, 48 | payload: fetch(`/api/projects/${projectId}/settings`, { 49 | method: "POST", 50 | headers: util.getJsonHeader(), 51 | body: JSON.stringify({ 52 | settings: newSettings, 53 | importMapping, 54 | }), 55 | }).then(util.restHandler), 56 | }) 57 | 58 | const initialState = { 59 | projectList: {}, 60 | } 61 | 62 | // reducer 63 | 64 | export const ProjectReducer = (state = initialState, action) => { 65 | switch (action.type) { 66 | case `${projectActions.GET_PROJECTS}${util.actionTypeSuffixes.fulfilled}`: { 67 | return { 68 | ...state, 69 | projectList: util.createIdMap(action.payload), 70 | } 71 | } 72 | case `${projectActions.DELETE_PROJECT}${util.actionTypeSuffixes.fulfilled}`: { 73 | const projectList = { ...state.projectList } 74 | delete projectList[action.payload.projectId] 75 | Alert.success("Project deleted") 76 | return { 77 | ...state, 78 | projectList, 79 | } 80 | } 81 | case `${projectActions.CREATE_PROJECT}${util.actionTypeSuffixes.fulfilled}`: { 82 | const projectList = { ...state.projectList } 83 | projectList[action.payload._id] = action.payload 84 | Alert.success("Project created") 85 | return { 86 | ...state, 87 | projectList, 88 | } 89 | } 90 | case `${projectActions.IMPORT_PROJECT_SETTINGS}${util.actionTypeSuffixes.fulfilled}`: { 91 | const projectList = { ...state.projectList } 92 | projectList[action.payload._id] = action.payload 93 | Alert.success("Project settings imported. Reloading page.") 94 | return { 95 | ...state, 96 | projectList, 97 | } 98 | } 99 | case `${projectActions.UPDATE_ANNOTATION_TYPES}${util.actionTypeSuffixes.fulfilled}`: { 100 | const projectList = { ...state.projectList } 101 | projectList[action.payload._id] = action.payload 102 | Alert.success("Project updated") 103 | return { 104 | ...state, 105 | projectList, 106 | } 107 | } 108 | default: 109 | return state 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /frontend/src/redux/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux" 2 | import { ProjectReducer } from "./projects" 3 | import { ImageReducer } from "./images" 4 | import { AnnotationReducer } from "./annotations" 5 | 6 | // register all reducers for the various store spaces 7 | export const rootReducer = combineReducers({ 8 | projects: ProjectReducer, 9 | images: ImageReducer, 10 | annotations: AnnotationReducer, 11 | }) 12 | 13 | export default rootReducer 14 | -------------------------------------------------------------------------------- /frontend/src/tools/EditTool.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Alert from "react-s-alert" 3 | import common from "./common" 4 | import mixins from "../mixins" 5 | 6 | const style = { 7 | errorMessage: { 8 | ...mixins.smallFont, 9 | color: "#a33", 10 | }, 11 | controlOptionContainer: { 12 | display: "grid", 13 | gridTemplateColumns: "1fr 1fr", 14 | border: "1px solid #ccc", 15 | marginBottom: "5px", 16 | }, 17 | controlOption: selected => ({ 18 | padding: "8px", 19 | textAlign: "center", 20 | background: selected ? mixins.mainColor.color : "#fff", 21 | color: selected ? "#fff" : "#333", 22 | cursor: selected ? "normal" : "pointer", 23 | }), 24 | label: { 25 | textDecoration: "underline", 26 | fontWeight: "600", 27 | fontSize: "12px", 28 | paddingBottom: "5px", 29 | }, 30 | } 31 | 32 | class EditToolOptionPanel extends React.Component { 33 | constructor(props) { 34 | super(props) 35 | this.state = { 36 | selected: false, 37 | isControlPointEdit: false, 38 | newPolygon: null, 39 | settingMoveControlPoint: true, // true or false, false will create new control points 40 | } 41 | 42 | this.reset = this.reset.bind(this) 43 | this.save = this.save.bind(this) 44 | this.changeMoveControlPoint = this.changeMoveControlPoint.bind(this) 45 | this.emitWarning = this.emitWarning.bind(this) 46 | } 47 | 48 | changeMoveControlPoint(newValue) { 49 | return () => { 50 | if (this.state.settingMoveControlPoint === newValue) { 51 | return 52 | } 53 | 54 | this.setState({ 55 | settingMoveControlPoint: newValue, 56 | }) 57 | } 58 | } 59 | 60 | emitWarning(message) { 61 | Alert.warning(message) 62 | } 63 | 64 | reset() { 65 | this.setState({ 66 | selected: false, 67 | isControlPointEdit: false, 68 | newPolygon: null, 69 | settingMoveControlPoint: true, 70 | }) 71 | } 72 | 73 | save() { 74 | this.props.onSave(this.state.newPolygon) 75 | } 76 | 77 | render() { 78 | const initControlPointEditMessage = this.state.settingMoveControlPoint 79 | ? "Please select a control point and drag it to a new position" 80 | : "Please click in between 2 control points to insert a new one" 81 | 82 | return ( 83 |
84 | {this.state.selected === false ? ( 85 | Please select an annotation to edit 86 | ) : null} 87 | 88 | {this.state.selected === true && this.state.isControlPointEdit === true ? ( 89 |
90 |
Control Points
91 |
92 |
99 | Move 100 |
101 |
105 | Insert 106 |
107 |
108 |
109 | ) : null} 110 | 111 | {this.state.selected === true && 112 | this.state.isControlPointEdit === true && 113 | this.state.newPolygon == null ? ( 114 |
115 | {initControlPointEditMessage} 116 |
117 | ) : null} 118 | 119 | {this.state.selected === true && this.state.isControlPointEdit === false ? ( 120 | 121 | Sorry, annotations drawn with the freehand tool currently cannot be modified. 122 | 123 | ) : null} 124 | 125 |
126 | {this.state.selected === true ? ( 127 | 130 | ) : null} 131 | {this.state.newPolygon != null ? ( 132 | 135 | ) : null} 136 |
137 |
138 | ) 139 | } 140 | } 141 | 142 | class EditTool extends common.CommonTool { 143 | constructor(toolId, canvas, zoom, redraw, add, remove) { 144 | super(toolId, canvas, zoom, redraw, add, remove) 145 | this.originalPolygon = null 146 | this.selectedPolygon = null 147 | this.selectedPolygonTool = null 148 | this.allPolygons = [] 149 | this.controlPoint = null 150 | this.movedControlPoint = null 151 | } 152 | 153 | getCursor() { 154 | return "crosshair" 155 | } 156 | 157 | getOptionPanel() { 158 | const handleCancel = () => { 159 | this.cancel(true) 160 | } 161 | const handleSaveEvent = newPolygon => { 162 | this.add(newPolygon) 163 | this.cancel(false) 164 | } 165 | 166 | return ( 167 | { 171 | this.optionPanel = el 172 | }} 173 | /> 174 | ) 175 | } 176 | 177 | getClosestPoint(mousePos) { 178 | const diff = this.selectedPolygon.shape 179 | .map(item => ({ 180 | shape: item, 181 | diff: Math.abs(item[0] - mousePos.x) + Math.abs(item[1] - mousePos.y), 182 | })) 183 | .sort((a, b) => a.diff - b.diff) 184 | return diff[0].shape 185 | } 186 | 187 | setPolygons(polygons) { 188 | this.allPolygons = polygons 189 | } 190 | 191 | cancel(reAdd = true) { 192 | if (reAdd === true && this.originalPolygon != null) { 193 | this.add(this.originalPolygon) 194 | } 195 | this.originalPolygon = null 196 | this.selectedPolygon = null 197 | this.selectedPolygonTool = null 198 | this.controlPoint = null 199 | this.movedControlPoint = null 200 | this.optionPanel.reset() 201 | this.redraw() 202 | } 203 | 204 | handleClick(ev) { 205 | const mousePos = this.getMousePos(ev) 206 | if (this.selectedPolygon == null) { 207 | this.allPolygons.forEach((polygon, index) => { 208 | if (common.isWithinPolygon([mousePos.x, mousePos.y], polygon.shape)) { 209 | this.selectedPolygon = { ...polygon } // this will be updated 210 | this.originalPolygon = { ...polygon } // save in case we cancel 211 | this.selectedPolygonTool = polygon.tool 212 | this.optionPanel.setState({ 213 | selected: true, 214 | isControlPointEdit: this.isPointEdit(), 215 | }) 216 | this.remove(polygon.shape) 217 | } 218 | }) 219 | } else if (this.isPointEdit() && !this.isControlPointMove()) { 220 | // insert mode for control points, find closest 2 points 221 | const distances = [] 222 | this.selectedPolygon.shape.forEach((coordinates, index) => { 223 | distances.push({ 224 | index, 225 | distance: Math.sqrt((mousePos.x - coordinates[0]) ** 2 + (mousePos.y - coordinates[1]) ** 2), 226 | }) 227 | }) 228 | const sortedDistances = distances.sort((a, b) => a.distance - b.distance) 229 | const selected = [sortedDistances[0], sortedDistances[1]] 230 | if ( 231 | Math.abs(selected[0].index - selected[1].index) !== 1 && 232 | selected[0].index !== 0 && 233 | selected[1].index !== 0 234 | ) { 235 | // oh, oh, clicked between 2 not sequential points 236 | this.optionPanel.emitWarning("Please select in between 2 neighbouring control points") 237 | return 238 | } 239 | 240 | let minIndex = Math.min(selected[0].index, selected[1].index) 241 | const maxIndex = Math.max(selected[0].index, selected[1].index) 242 | if (minIndex === 0 && maxIndex === sortedDistances.length - 1) { 243 | // in between first and last 244 | minIndex = maxIndex 245 | } 246 | 247 | // new list of coordinates 248 | const result = [...this.selectedPolygon.shape] 249 | // insert new control point 250 | result.splice(minIndex + 1, 0, [mousePos.x, mousePos.y]) 251 | 252 | // inform the option panel about new data 253 | this.selectedPolygon.shape = result 254 | this.optionPanel.setState({ 255 | newPolygon: { ...this.selectedPolygon }, 256 | }) 257 | } 258 | 259 | this.redraw() 260 | } 261 | 262 | handleMouseDown(ev) { 263 | const mousePos = this.getMousePos(ev) 264 | if (this.selectedPolygon != null) { 265 | if (this.isPointEdit() && this.isControlPointMove()) { 266 | // for adjusting control points 267 | this.controlPoint = this.getClosestPoint(mousePos) 268 | } 269 | } 270 | } 271 | 272 | handleMouseMove(ev) { 273 | if (this.controlPoint == null) { 274 | this.movedControlPoint = null 275 | return 276 | } 277 | const mousePos = this.getMousePos(ev) 278 | this.movedControlPoint = [mousePos.x, mousePos.y] 279 | this.redraw() 280 | } 281 | 282 | handleMouseUp(ev) { 283 | if (this.controlPoint == null) { 284 | return 285 | } 286 | const mousePos = this.getMousePos(ev) 287 | this.movedControlPoint = [mousePos.x, mousePos.y] 288 | // update polygon 289 | const result = [] 290 | this.selectedPolygon.shape.forEach(polygon => { 291 | if (this.isControlPoint(polygon)) { 292 | result.push([...this.movedControlPoint]) 293 | } else { 294 | result.push(polygon) 295 | } 296 | }) 297 | 298 | this.selectedPolygon.shape = result 299 | this.optionPanel.setState({ 300 | newPolygon: { ...this.selectedPolygon }, 301 | }) 302 | 303 | this.movedControlPoint = null 304 | this.controlPoint = null 305 | this.redraw() 306 | } 307 | 308 | isPointEdit() { 309 | if (this.selectedPolygonTool == null) { 310 | return false 311 | } 312 | const pointEditTools = ["spline", "polygon", "line"] 313 | return pointEditTools.indexOf(this.selectedPolygonTool) !== -1 314 | } 315 | 316 | isControlPointMove() { 317 | if (this.isPointEdit() === false) { 318 | return false 319 | } 320 | return this.optionPanel.state.settingMoveControlPoint 321 | } 322 | 323 | isControlPoint(coordinate) { 324 | return ( 325 | this.controlPoint != null && 326 | this.controlPoint[0] === coordinate[0] && 327 | this.controlPoint[1] === coordinate[1] 328 | ) 329 | } 330 | 331 | redraw() { 332 | super.redraw() 333 | 334 | if (this.selectedPolygon != null) { 335 | const { shape } = this.selectedPolygon 336 | // draw outline for the polygon in question 337 | this.ctx.strokeStyle = "#f00" 338 | this.ctx.lineWidth = 3 339 | this.ctx.setLineDash([2, 4]) 340 | this.ctx.beginPath() 341 | shape.forEach((coords, index) => { 342 | let finalPoint = coords 343 | if (this.isControlPoint(coords) && this.movedControlPoint != null) { 344 | finalPoint = this.movedControlPoint 345 | } 346 | if (index === 0) { 347 | this.ctx.moveTo(finalPoint[0] * this.zoom, finalPoint[1] * this.zoom) 348 | } else { 349 | this.ctx.lineTo(finalPoint[0] * this.zoom, finalPoint[1] * this.zoom) 350 | } 351 | }) 352 | if (this.selectedPolygon.tool !== "line") { 353 | this.ctx.closePath() 354 | } 355 | this.ctx.stroke() 356 | 357 | // decide whether to display corner hooks 358 | if (this.isPointEdit()) { 359 | // this.ctx.restore() 360 | shape.forEach(coordinate => { 361 | this.ctx.beginPath() 362 | this.ctx.fillStyle = "#fff" 363 | let finalPoint = coordinate 364 | if (this.isControlPoint(coordinate)) { 365 | this.ctx.fillStyle = "#000" 366 | if (this.movedControlPoint != null) { 367 | finalPoint = this.movedControlPoint 368 | } 369 | } 370 | this.ctx.arc(finalPoint[0] * this.zoom, finalPoint[1] * this.zoom, 5, 0, 2 * Math.PI, false) 371 | this.ctx.fill() 372 | this.ctx.lineWidth = 2 373 | this.ctx.strokeStyle = "#f00" 374 | this.ctx.stroke() 375 | }) 376 | } 377 | } 378 | } 379 | } 380 | 381 | export default EditTool 382 | -------------------------------------------------------------------------------- /frontend/src/tools/EraserTool.js: -------------------------------------------------------------------------------- 1 | import common from "./common" 2 | 3 | class EraserTool extends common.CommonTool { 4 | constructor(toolId, canvas, zoom, redraw, add, remove) { 5 | super(toolId, canvas, zoom, redraw, add, remove) 6 | this.polygons = null 7 | } 8 | 9 | getCursor() { 10 | return "crosshair" 11 | } 12 | 13 | setPolygons(polygons) { 14 | this.polygons = polygons 15 | } 16 | 17 | handleClick(ev) { 18 | const mousePos = common.getMousePos(this.canvas, ev, this.zoom) 19 | let removePolygon = null 20 | this.polygons.forEach(polygon => { 21 | if (common.isWithinPolygon([mousePos.x, mousePos.y], polygon.shape)) { 22 | // remove this 23 | removePolygon = polygon 24 | } 25 | }) 26 | if (removePolygon != null) { 27 | this.remove(removePolygon.shape) 28 | } 29 | } 30 | } 31 | 32 | export default EraserTool 33 | -------------------------------------------------------------------------------- /frontend/src/tools/FreeHandTool.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import common from "./common" 3 | import mixins from "../mixins" 4 | 5 | class FreeHandToolOptionPanel extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | this.state = { 9 | init: false, 10 | } 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 | {this.state.init ? ( 17 | 20 | ) : ( 21 | Start drawing by dragging over the canvas 22 | )} 23 |
24 | ) 25 | } 26 | } 27 | 28 | class FreeHandTool extends common.CommonTool { 29 | constructor(toolId, canvas, zoom, redraw, add) { 30 | super(toolId, canvas, zoom, redraw, add) 31 | this.currentShape = null 32 | this.optionPanel = null 33 | this.isDrawing = false 34 | } 35 | 36 | getCursor() { 37 | return "crosshair" 38 | } 39 | 40 | getOptionPanel() { 41 | const handleSaveEvent = () => { 42 | this.add([...this.currentShape]) 43 | this.currentShape = null 44 | this.isDrawing = false 45 | this.optionPanel.setState({ 46 | init: false, 47 | }) 48 | } 49 | return ( 50 | { 53 | this.optionPanel = el 54 | }} 55 | /> 56 | ) 57 | } 58 | 59 | handleMouseDown(ev) { 60 | const mousePos = this.getMousePos(ev) 61 | this.isDrawing = true 62 | this.currentShape = [[mousePos.x, mousePos.y]] 63 | if (this.optionPanel.state.init === true) { 64 | this.optionPanel.setState({ 65 | init: false, 66 | }) 67 | } 68 | this.redraw() 69 | } 70 | 71 | handleMouseUp(ev) { 72 | if (!this.isDrawing) { 73 | return 74 | } 75 | this.isDrawing = false 76 | this.optionPanel.setState({ 77 | init: true, 78 | }) 79 | this.redraw() 80 | } 81 | 82 | handleMouseMove(ev) { 83 | if (!this.isDrawing) { 84 | return 85 | } 86 | const mousePos = this.getMousePos(ev) 87 | this.currentShape.push([mousePos.x, mousePos.y]) 88 | this.redraw() 89 | } 90 | 91 | redraw() { 92 | super.redraw() 93 | if (this.currentShape != null) { 94 | if (this.isDrawing) { 95 | this.drawPolygon(this.currentShape, false) 96 | } else { 97 | this.drawPolygon(this.currentShape) 98 | } 99 | } 100 | } 101 | } 102 | 103 | export default FreeHandTool 104 | -------------------------------------------------------------------------------- /frontend/src/tools/LineTool.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import mixins from "../mixins" 3 | import PolygonTool from "./PolygonTool" 4 | 5 | class LineToolOptionPanel extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | this.state = { 9 | currentPolygon: null, 10 | } 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 | {this.state.currentPolygon != null && this.state.currentPolygon.length > 2 ? ( 17 | 20 | ) : ( 21 | Start clicking points in the canvas to compose a line. 22 | )} 23 |
24 | ) 25 | } 26 | } 27 | 28 | class LineTool extends PolygonTool { 29 | getOptionPanel() { 30 | const handleSaveEvent = () => { 31 | this.add([...this.currentPolygon]) 32 | this.currentPolygon = null 33 | } 34 | 35 | return ( 36 | { 38 | this.optionPanel = el 39 | }} 40 | onSave={handleSaveEvent} 41 | /> 42 | ) 43 | } 44 | 45 | drawPolygon(polygon, close = true) { 46 | super.drawPolygon(polygon, false) 47 | } 48 | } 49 | 50 | export default LineTool 51 | -------------------------------------------------------------------------------- /frontend/src/tools/PolygonTool.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import common from "./common" 3 | import mixins from "../mixins" 4 | 5 | class PolygonToolOptionPanel extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | this.state = { 9 | currentPolygon: null, 10 | } 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 | {this.state.currentPolygon != null && this.state.currentPolygon.length > 2 ? ( 17 | 20 | ) : ( 21 | Start clicking points in the canvas to compose a polygon. 22 | )} 23 |
24 | ) 25 | } 26 | } 27 | 28 | class PolygonTool extends common.CommonTool { 29 | constructor(toolId, canvas, zoom, redraw, add) { 30 | super(toolId, canvas, zoom, redraw, add) 31 | this.currentPolygon = null 32 | this.optionPanel = null 33 | } 34 | 35 | getCursor() { 36 | return "crosshair" 37 | } 38 | 39 | getOptionPanel() { 40 | const handleSaveEvent = () => { 41 | this.add([...this.currentPolygon]) 42 | this.currentPolygon = null 43 | } 44 | 45 | return ( 46 | { 48 | this.optionPanel = el 49 | }} 50 | onSave={handleSaveEvent} 51 | /> 52 | ) 53 | } 54 | 55 | handleClick(ev) { 56 | const mousePos = this.getMousePos(ev) 57 | if (this.currentPolygon == null) { 58 | // create initial polygon 59 | this.currentPolygon = [[mousePos.x, mousePos.y]] 60 | this.redraw() 61 | return 62 | } 63 | 64 | this.currentPolygon.push([mousePos.x, mousePos.y]) 65 | this.redraw() 66 | } 67 | 68 | redraw() { 69 | this.redrawParent() 70 | 71 | this.optionPanel.setState({ 72 | currentPolygon: this.currentPolygon, 73 | }) 74 | 75 | if (this.currentPolygon != null) { 76 | if (this.currentPolygon.length === 1) { 77 | // only one entry, just draw a single dot 78 | this.ctx.fillStyle = "#0f0" 79 | this.ctx.fillRect(this.currentPolygon[0][0] * this.zoom, this.currentPolygon[0][1] * this.zoom, 1, 1) 80 | return 81 | } 82 | 83 | // draw polygon lines of current polygon 84 | this.drawPolygon(this.currentPolygon) 85 | } 86 | } 87 | } 88 | 89 | export default PolygonTool 90 | -------------------------------------------------------------------------------- /frontend/src/tools/SplineTool.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import common from "./common" 3 | import mixins from "../mixins" 4 | 5 | const DEFAULT_NUMBER_CONTROL = 8 6 | const SESSION_STORAGE_KEY_CONTROL_POINTS = "SplineTool-NumberOfControlPoints" 7 | 8 | const getNumberOfControls = () => { 9 | let numberControl = DEFAULT_NUMBER_CONTROL 10 | const preferredDefault = sessionStorage.getItem(SESSION_STORAGE_KEY_CONTROL_POINTS) 11 | if (preferredDefault != null) { 12 | numberControl = parseInt(preferredDefault, 10) 13 | } 14 | return numberControl 15 | } 16 | 17 | class SplineToolOptionPanel extends React.Component { 18 | constructor(props) { 19 | super(props) 20 | this.state = { 21 | init: false, 22 | numberControl: getNumberOfControls(), 23 | } 24 | 25 | this.changeControlPoints = this.changeControlPoints.bind(this) 26 | } 27 | 28 | changeControlPoints(ev) { 29 | const number = parseInt(ev.target.value, 10) 30 | if (isNaN(number)) { 31 | return 32 | } 33 | sessionStorage.setItem(SESSION_STORAGE_KEY_CONTROL_POINTS, number) 34 | this.setState({ 35 | numberControl: number, 36 | }) 37 | this.props.changeControlPoints(number) 38 | } 39 | 40 | render() { 41 | if (this.state.init) { 42 | return ( 43 |
44 | 45 | 52 | 53 |
54 | 57 |
58 |
59 | ) 60 | } 61 | return ( 62 |
63 | Please draw a rectangle around the area of interest. 64 |
65 | ) 66 | } 67 | } 68 | 69 | class SplineTool extends common.CommonTool { 70 | constructor(toolId, canvas, zoom, redraw, add) { 71 | super(toolId, canvas, zoom, redraw, add) 72 | this.rectangle = null 73 | this.currentShape = null 74 | this.isDrawing = false 75 | this.optionPanel = null 76 | this.changeNumberOfControls = this.changeNumberOfControls.bind(this) 77 | } 78 | 79 | getCursor() { 80 | return "crosshair" 81 | } 82 | 83 | changeNumberOfControls(newNumber) { 84 | this.currentShape = this.getControlPoints(newNumber) 85 | this.redraw() 86 | } 87 | 88 | getOptionPanel() { 89 | const handleSaveEvent = () => { 90 | this.add([...this.currentShape]) 91 | this.currentShape = null 92 | this.rectangle = null 93 | this.isDrawing = false 94 | this.optionPanel.setState({ 95 | init: false, 96 | }) 97 | } 98 | return ( 99 | { 101 | this.optionPanel = el 102 | }} 103 | changeControlPoints={this.changeNumberOfControls} 104 | onSave={handleSaveEvent} 105 | /> 106 | ) 107 | } 108 | 109 | handleClick(ev) {} 110 | 111 | handleMouseDown(ev) { 112 | const mousePos = this.getMousePos(ev) 113 | this.isDrawing = true 114 | this.currentShape = null 115 | this.rectangle = [[mousePos.x, mousePos.y]] 116 | } 117 | 118 | handleMouseUp(ev) { 119 | this.currentShape = this.getControlPoints(getNumberOfControls()) 120 | this.optionPanel.setState({ 121 | init: true, 122 | }) 123 | this.redraw() 124 | } 125 | 126 | handleMouseMove(ev) { 127 | if (!this.isDrawing || this.currentShape != null) { 128 | return 129 | } 130 | const mousePos = this.getMousePos(ev) 131 | const current = [mousePos.x, mousePos.y] 132 | if (this.rectangle.length === 1) { 133 | this.rectangle.push(current) 134 | } else { 135 | this.rectangle[1] = current 136 | } 137 | this.redraw() 138 | } 139 | 140 | redraw() { 141 | super.redraw() 142 | if (this.currentShape != null) { 143 | this.drawPolygon(this.currentShape) 144 | } else if (this.rectangle != null && this.rectangle.length === 2) { 145 | const start = this.rectangle[0] 146 | const end = this.rectangle[1] 147 | 148 | this.ctx.fillStyle = "rgba(255, 255, 255, 0.3)" 149 | this.ctx.fillRect( 150 | start[0] * this.zoom, 151 | start[1] * this.zoom, 152 | (end[0] - start[0]) * this.zoom, 153 | (end[1] - start[1]) * this.zoom 154 | ) 155 | this.ctx.fill() 156 | this.ctx.closePath() 157 | } 158 | } 159 | 160 | getControlPoints(numPoints) { 161 | // reset control points 162 | const controlPoints = [] 163 | 164 | const startX = this.rectangle[0][0] 165 | const startY = this.rectangle[0][1] 166 | 167 | const endX = this.rectangle[1][0] 168 | const endY = this.rectangle[1][1] 169 | 170 | // find middle 171 | const width = endX - startX 172 | const height = endY - startY 173 | 174 | // create control points and cater for rounding issues with 355 degree (to prevent 0 AND 360) 175 | for (let angle = 0; angle < 355; angle += 360 / numPoints) { 176 | // 0 is midX and midY, add the radius (half width / half height) multiplied with the sin(angle) / cos(angle) 177 | controlPoints.push([ 178 | startX + Math.round(width / 2) + Math.round((width / 2) * Math.cos((Math.PI * angle) / 180.0)), 179 | startY + Math.round(height / 2) + Math.round((height / 2) * Math.sin((Math.PI * angle) / 180.0)), 180 | ]) 181 | } 182 | return controlPoints 183 | } 184 | } 185 | 186 | export default SplineTool 187 | -------------------------------------------------------------------------------- /frontend/src/tools/common.js: -------------------------------------------------------------------------------- 1 | import classifyPoint from "robust-point-in-polygon" 2 | 3 | const getMousePos = (canvas, evt, zoom) => { 4 | const rect = canvas.getBoundingClientRect() 5 | const x = Math.round((evt.clientX - rect.left) / zoom) 6 | const y = Math.round((evt.clientY - rect.top) / zoom) 7 | return { x, y } 8 | } 9 | 10 | class CommonTool { 11 | constructor(toolId, canvas, zoom, redraw, add, remove) { 12 | this.id = toolId 13 | this.canvas = canvas 14 | this.ctx = canvas.getContext("2d") 15 | this.redrawParent = redraw 16 | this.addElement = add 17 | this.removeElement = remove 18 | this.zoom = zoom 19 | } 20 | 21 | add(shape) { 22 | this.addElement(shape) 23 | } 24 | 25 | drawPolygon(polygon, close = true) { 26 | // draw polygon lines of current polygon 27 | this.ctx.strokeStyle = "#0f0" 28 | this.ctx.beginPath() 29 | polygon.forEach((coords, index) => { 30 | if (index === 0) { 31 | this.ctx.moveTo(coords[0] * this.zoom, coords[1] * this.zoom) 32 | } else { 33 | this.ctx.lineTo(coords[0] * this.zoom, coords[1] * this.zoom) 34 | } 35 | }) 36 | if (close) { 37 | this.ctx.closePath() 38 | } 39 | this.ctx.stroke() 40 | } 41 | 42 | getCursor() { 43 | return null 44 | } 45 | 46 | getMousePos(ev) { 47 | return getMousePos(this.canvas, ev, this.zoom) 48 | } 49 | 50 | updateZoom(newZoom) { 51 | this.zoom = newZoom 52 | this.redraw() 53 | } 54 | 55 | redraw() { 56 | this.redrawParent() 57 | } 58 | 59 | remove(shape) { 60 | this.removeElement(shape) 61 | } 62 | } 63 | 64 | export default { 65 | getMousePos, 66 | 67 | CommonTool, 68 | 69 | eraserTool: "eraser", 70 | 71 | isWithinPolygon(point, polygon) { 72 | // first check if it is one of the corners 73 | let found = false 74 | polygon.forEach(coordinate => { 75 | if (coordinate[0] === point[0] && coordinate[1] === point[1]) { 76 | found = true 77 | } 78 | }) 79 | 80 | if (found) { 81 | return true 82 | } 83 | return classifyPoint(polygon, point) !== 1 84 | }, 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Peter Ilfrich 3 | * 4 | * 5 | */ 6 | import moment from "moment" 7 | 8 | /** 9 | * Utilities used across components 10 | */ 11 | export default { 12 | /** 13 | * Uses the hard-coded date format to format the provided date. If no valid date is provided, null is returned. 14 | * @param {(Date|moment.Moment)} date - the date to format 15 | * @returns {String} the formatted string or null 16 | */ 17 | formatDate(date) { 18 | const d = moment(date) 19 | if (d.isValid()) { 20 | return moment(date).format("YYYY-MM-DD") 21 | } 22 | return null 23 | }, 24 | 25 | /** 26 | * Returns the action.type suffixes for the promise redux middleware (used in the reducers) 27 | */ 28 | actionTypeSuffixes: { 29 | pending: "_PENDING", 30 | fulfilled: "_FULFILLED", 31 | rejected: "_REJECTED", 32 | }, 33 | 34 | normalise(val, min, max) { 35 | if (val < min) { 36 | return 0.0 37 | } 38 | if (val > max) { 39 | return 1.0 40 | } 41 | let minValue = min 42 | let maxValue = max 43 | if (max < min) { 44 | minValue = max 45 | maxValue = min 46 | } 47 | 48 | if (max === min) { 49 | return 1.0 50 | } 51 | return (val - minValue) / (maxValue - minValue) 52 | }, 53 | 54 | getJsonHeader() { 55 | return { 56 | "Content-Type": "application/json", 57 | } 58 | }, 59 | 60 | getAuthHeader() { 61 | return { 62 | Authorization: localStorage.getItem("auth_token"), 63 | } 64 | }, 65 | 66 | logout() { 67 | localStorage.removeItem("auth_token") 68 | }, 69 | 70 | getApiRequestHeader() { 71 | return { 72 | ...this.getAuthHeader(), 73 | ...this.getJsonHeader(), 74 | } 75 | }, 76 | 77 | restHandler(response) { 78 | return new Promise((resolve, reject) => { 79 | if (response.status >= 400) { 80 | reject(new Error("Internal server error")) 81 | return 82 | } 83 | resolve(response.json()) 84 | }) 85 | }, 86 | 87 | setAuthToken(token) { 88 | localStorage.setItem("auth_token", token) 89 | }, 90 | 91 | createIdMap(objectList) { 92 | const result = {} 93 | if (objectList == null || objectList.forEach == null) { 94 | return result 95 | } 96 | objectList.forEach(item => { 97 | if (item._id != null) { 98 | result[item._id] = item 99 | } else if (item.id != null) { 100 | result[item.id] = item 101 | } 102 | }) 103 | return result 104 | }, 105 | 106 | idMapToList(idMap) { 107 | return Object.values(idMap) 108 | }, 109 | 110 | getImagePath: (projectId, imageId) => `/image/${projectId}/${imageId}`, 111 | 112 | taskTypes: { 113 | predict: "PREDICT", 114 | training: "TRAIN", 115 | }, 116 | 117 | getLocalStorageNumber(key, defaultValue) { 118 | const stored = localStorage.getItem(key) 119 | if (stored == null || stored.trim() === "") { 120 | return defaultValue 121 | } 122 | return parseInt(stored, 10) 123 | }, 124 | 125 | getLocalStorageFloat(key, defaultValue) { 126 | const stored = localStorage.getItem(key) 127 | if (stored == null || stored.trim() === "") { 128 | return defaultValue 129 | } 130 | return parseFloat(stored) 131 | }, 132 | 133 | hexToRgb(hexValue, alpha) { 134 | const hex = hexValue.replace("#", "") 135 | const r = parseInt(hex.length === 3 ? hex.slice(0, 1).repeat(2) : hex.slice(0, 2), 16) 136 | const g = parseInt(hex.length === 3 ? hex.slice(1, 2).repeat(2) : hex.slice(2, 4), 16) 137 | const b = parseInt(hex.length === 3 ? hex.slice(2, 3).repeat(2) : hex.slice(4, 6), 16) 138 | if (alpha) { 139 | return `rgba(${r}, ${g}, ${b}, ${alpha})` 140 | } 141 | 142 | return `rgb(${r}, ${g}, ${b})` 143 | }, 144 | } 145 | -------------------------------------------------------------------------------- /migrate_data.py: -------------------------------------------------------------------------------- 1 | """ 2 | This script allows to copy one or multiple projects from one annotator instance to another. Simply run this program with 3 | python3 after installing `requests` 4 | """ 5 | import requests 6 | import sys 7 | import os 8 | import copy 9 | import zipfile 10 | 11 | 12 | MIGRATION_DIR = os.path.join(os.path.dirname(__file__), "_migration") 13 | 14 | 15 | def _read_projects(source_api): 16 | return requests.get("{}/api/projects".format(source_api)).json() 17 | 18 | 19 | def _get_project_configuration(source_server): 20 | print("\nReading projects\n") 21 | project_list = _read_projects(source_server) 22 | 23 | print("Projects\n========") 24 | print("0 - ALL Projects") 25 | for index, project in enumerate(project_list): 26 | print("{} - {}".format(index + 1, project["name"])) 27 | 28 | print("\nIf you want to select multiple projects, provide their number separated by space, e.g. '3 5 6'\n") 29 | project_indexes = input("Projects to migrated: ") 30 | if project_indexes == "0": 31 | projects = list(range(1, len(project_list) + 1)) 32 | else: 33 | projects = [] 34 | project_index_list = project_indexes.split(" ") 35 | for item in project_index_list: 36 | projects.append(int(item)) 37 | 38 | return projects 39 | 40 | 41 | def _get_configuration(): 42 | source_server = input("Please provide the URL to the source server: ") 43 | if source_server == "": 44 | source_server = "http://localhost:5555" 45 | target_server = input("Please provide the URL to the target server: ") 46 | if target_server == "": 47 | target_server = "http://localhost:5556" 48 | projects = _get_project_configuration(source_server) 49 | return source_server, target_server, projects 50 | 51 | 52 | def _create_project(target_api, project): 53 | new_project = copy.deepcopy(project) 54 | # reset counters and remove id 55 | del new_project["_id"] 56 | new_project["image_count"] = 0 57 | new_project["annotation_count"] = 0 58 | # create project 59 | created_project = requests.post("{}/api/projects".format(target_api), json=new_project).json() 60 | return created_project 61 | 62 | 63 | def _save_file(image_response, image_path): 64 | if image_response.status_code == 200: 65 | with open(image_path, 'wb') as f: 66 | f.write(image_response.content) 67 | return image_path 68 | raise ConnectionError("Could not fetch image from source API") 69 | 70 | 71 | def _clean_migration_dir(): 72 | for root, dirs, files in os.walk(MIGRATION_DIR): 73 | for f in files: 74 | combined_path = os.path.join(root, f) 75 | os.unlink(combined_path) 76 | 77 | 78 | def migration_annotations(source, target, source_image, target_image): 79 | # get annotations from source 80 | annotations = requests.get("{}/api/projects/{}/images/{}/annotations".format(source, source_image["projectId"], 81 | source_image["_id"])).json() 82 | 83 | if isinstance(annotations, dict): 84 | annotations = [annotations] 85 | 86 | for annotation in annotations: 87 | if "_id" not in annotation: 88 | # no annotations available 89 | return 90 | 91 | # clean up data and map to target system 92 | del annotation["_id"] 93 | annotation["imageId"] = target_image["_id"] 94 | annotation["projectId"] = target_image["projectId"] 95 | 96 | # save annotations for new image 97 | res = requests.post("{}/api/projects/{}/images/{}/annotations".format(target, target_image["projectId"], 98 | target_image["_id"]), json=annotation) 99 | if res.status_code != 200: 100 | raise ConnectionError("Failed to save annotations") 101 | 102 | 103 | def _compile_frame_set(source, source_image): 104 | print(f" - Compiling frame set for '{source_image['label']}' - this may take a while") 105 | temp_dir = os.path.join(MIGRATION_DIR, "_temp") 106 | if not os.path.exists(temp_dir): 107 | os.mkdir(temp_dir) 108 | source_files = [] 109 | for index in source_image["originalFileNames"]: 110 | r = requests.get("{}/image/{}/{}/{}".format(source, source_image["projectId"], source_image["_id"], index)) 111 | source_files.append(_save_file(r, os.path.join(temp_dir, source_image["originalFileNames"][index]))) 112 | 113 | zip_file = os.path.join(temp_dir, "temp.zip") 114 | zipf = zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) 115 | for src_file in source_files: 116 | zipf.write(src_file, os.path.basename(src_file)) 117 | zipf.close() 118 | for src_file in source_files: 119 | os.unlink(src_file) 120 | return temp_dir, zip_file 121 | 122 | 123 | def migrate_images(source, target, source_project, target_project): 124 | # get images from source 125 | source_images = requests.get("{}/api/projects/{}/images".format(source, source_project["_id"])).json() 126 | for image in source_images: 127 | print(" - Processing image '{}'".format(image["label"])) 128 | if "numFrames" in image: 129 | # frame set 130 | temp_dir, zip_path = _compile_frame_set(source, image) 131 | fp = open(zip_path, "rb") 132 | multipart_form_data = { 133 | "file": (os.path.basename(zip_path), fp, "application/zip"), 134 | } 135 | multipart_data = { 136 | "label": image["label"], 137 | } 138 | new_image = requests.post("{}/api/projects/{}/images".format(target, target_project["_id"]), 139 | files=multipart_form_data, data=multipart_data).json() 140 | # clean up 141 | fp.close() 142 | os.unlink(zip_path) 143 | # migrate annotations 144 | migration_annotations(source, target, image, new_image) 145 | else: 146 | # single image 147 | r = requests.get("{}/image/{}/{}".format(source, source_project["_id"], image["_id"])) 148 | 149 | file_path = _save_file(r, os.path.join(MIGRATION_DIR, image["originalFileNames"])) 150 | fp = open(file_path, "rb") 151 | multipart_form_data = { 152 | "file": (image["originalFileNames"], fp, image["contentType"]), 153 | } 154 | multipart_data = { 155 | "label": image["label"], 156 | } 157 | new_image = requests.post("{}/api/projects/{}/images".format(target, target_project["_id"]), 158 | files=multipart_form_data, data=multipart_data).json() 159 | fp.close() 160 | os.unlink(file_path) 161 | migration_annotations(source, target, image, new_image) 162 | 163 | 164 | def migrate_projects(source, target, project_list): 165 | source_projects = _read_projects(source) 166 | # stores list of source projects 167 | projects = {} 168 | # map source project _id to target _id 169 | project_id_mapping = {} 170 | for index in project_list: 171 | project = source_projects[index - 1] 172 | projects[project["_id"]] = project 173 | 174 | print(f"Processing {len(projects)} projects") 175 | for project_id in projects: 176 | print(f"- Migrating project '{projects[project_id]['name']}'") 177 | new_project = _create_project(target, projects[project_id]) 178 | project_id_mapping[project_id] = new_project["_id"] 179 | 180 | migrate_images(source, target, projects[project_id], new_project) 181 | 182 | 183 | if __name__ == "__main__": 184 | print(f" Using directory {MIGRATION_DIR} for migration") 185 | _clean_migration_dir() 186 | 187 | arguments = sys.argv 188 | arguments_length = len(arguments) 189 | if arguments_length == 1: 190 | # ask for everything 191 | s, t, p = _get_configuration() 192 | elif arguments_length >= 3: 193 | # source and target provided 194 | s = arguments[1] 195 | t = arguments[2] 196 | 197 | if arguments_length == 3: 198 | p = _get_project_configuration(s) 199 | elif arguments_length == 4 and arguments[3] == "0": 200 | all_p = _read_projects(s) 201 | p = list(range(1, len(all_p) + 1)) 202 | 203 | if not os.path.exists(MIGRATION_DIR): 204 | os.mkdir(MIGRATION_DIR) 205 | 206 | print("\n\n-> Start Migration\n") 207 | migrate_projects(s, t, p) 208 | 209 | _clean_migration_dir() 210 | print("\n\n-> MIGRATION SUCCESSFULLY COMPLETED\n") 211 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "annotator-frontend", 3 | "version": "1.0.0", 4 | "description": "The Annotator", 5 | "scripts": { 6 | "test": "", 7 | "build": "webpack -p", 8 | "lint": "eslint \"**/*.{js,mjs}\"", 9 | "format": "eslint --fix \"**/*.{js,mjs}\"", 10 | "hot-client": "webpack -d --watch", 11 | "clean": "rm -rf static/* && rm -rf templates/" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:ilfrich/annotator.git" 16 | }, 17 | "author": "Peter Ilfrich", 18 | "license": "Apache-2.0", 19 | "devDependencies": { 20 | "@babel/core": "^7.2.2", 21 | "@babel/node": "^7.2.2", 22 | "@babel/plugin-proposal-decorators": "^7.3.0", 23 | "@babel/preset-env": "^7.3.1", 24 | "@babel/preset-react": "^7.0.0", 25 | "babel-eslint": "^10.0.1", 26 | "babel-loader": "^8.0.5", 27 | "css-loader": "^2.1.1", 28 | "eslint": "^5.13.0", 29 | "eslint-config-airbnb": "^17.1.0", 30 | "eslint-config-prettier": "^4.0.0", 31 | "eslint-plugin-import": "^2.16.0", 32 | "eslint-plugin-jsx-a11y": "^6.2.1", 33 | "eslint-plugin-prettier": "^3.0.1", 34 | "eslint-plugin-react": "^7.19.0", 35 | "file-loader": "^3.0.1", 36 | "html-webpack-plugin": "^3.2.0", 37 | "husky": "^1.3.1", 38 | "prettier": "^1.16.4", 39 | "react-hot-loader": "^4.6.5", 40 | "style-loader": "^0.23.1", 41 | "url-loader": "^1.1.2", 42 | "webpack": "^4.29.3", 43 | "webpack-cli": "^3.2.3" 44 | }, 45 | "dependencies": { 46 | "@fortawesome/fontawesome-svg-core": "^1.2.17", 47 | "@fortawesome/free-solid-svg-icons": "^5.8.1", 48 | "@fortawesome/react-fontawesome": "^0.1.4", 49 | "jsx": "^0.9.89", 50 | "moment": "^2.24.0", 51 | "moment-timezone": "^0.5.27", 52 | "quick-n-dirty-react": "0.0.5", 53 | "quick-n-dirty-utils": "0.0.3", 54 | "react": "^16.9.0", 55 | "react-dom": "^16.9.0", 56 | "react-plotly.js": "^2.3.0", 57 | "react-redux": "^7.1.1", 58 | "react-router": "^5.0.1", 59 | "react-router-dom": "^5.0.1", 60 | "react-s-alert": "^1.4.1", 61 | "redux": "^4.0.4", 62 | "redux-promise-middleware": "^5.1.1", 63 | "robust-point-in-polygon": "^1.0.3" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytz 2 | flask 3 | setuptools 4 | python-dotenv 5 | pbu>=1.0.0 6 | pbumongo>=1.0.0 7 | Pillow 8 | pymongo 9 | requests 10 | -------------------------------------------------------------------------------- /runner.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from pbu import Logger 3 | from config import load_config, get_log_folder, get_mongodb_config 4 | from storage.project_store import ProjectStore 5 | from storage.image_store import ImageStore 6 | from storage.annotation_store import AnnotationStore 7 | import api.static_api as static_api 8 | import api.project_api as project_api 9 | import api.annotation_api as annotation_api 10 | import api.image_api as image_api 11 | 12 | if __name__ == "__main__": 13 | logger = Logger("MAIN", log_folder=get_log_folder()) 14 | logger.info("==========================================") 15 | logger.info(" Starting application") 16 | logger.info("==========================================") 17 | 18 | # load config from .env file 19 | config = load_config() 20 | 21 | # ---- database and stores ---- 22 | # fetch mongo config 23 | mongo_url, mongo_db = get_mongodb_config() 24 | 25 | # initialise stores 26 | stores = { 27 | "projects": ProjectStore(mongo_url=mongo_url, mongo_db=mongo_db, collection_name="projects"), 28 | "images": ImageStore(mongo_url=mongo_url, mongo_db=mongo_db, collection_name="images"), 29 | "annotations": AnnotationStore(mongo_url=mongo_url, mongo_db=mongo_db, collection_name="annotations"), 30 | } 31 | 32 | # ---- API ---- 33 | # create flask app 34 | app = Flask(__name__) 35 | # register endpoints 36 | static_api.register_endpoints(app) 37 | project_api.register_endpoints(app, stores) 38 | image_api.register_endpoints(app, stores) 39 | annotation_api.register_endpoints(app, stores) 40 | 41 | # start flask app 42 | app.run(host='0.0.0.0', port=5555, debug=config["IS_DEBUG"]) 43 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max-line-length = 120 -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilfrich/annotator/c5f6add10288975e4472515d8921c8d0dabecda0/static/favicon.png -------------------------------------------------------------------------------- /storage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilfrich/annotator/c5f6add10288975e4472515d8921c8d0dabecda0/storage/__init__.py -------------------------------------------------------------------------------- /storage/annotation_store.py: -------------------------------------------------------------------------------- 1 | from pbumongo import AbstractMongoStore 2 | 3 | 4 | class Annotation: 5 | """ 6 | Object class representing a document in this database collection. 7 | """ 8 | 9 | def __init__(self): 10 | self.project_id = None 11 | self.image_id = None 12 | self.shapes = [] 13 | self.id = None 14 | self.frame_num = None 15 | 16 | def to_json(self): 17 | """ 18 | Serialises the current instance into JSON 19 | :return: a dictionary containing the fields and values of this instance 20 | """ 21 | result = {} 22 | if self.project_id is not None: 23 | result["projectId"] = self.project_id 24 | if self.image_id is not None: 25 | result["imageId"] = self.image_id 26 | if self.shapes is not None: 27 | result["shapes"] = self.shapes 28 | if self.frame_num is not None: 29 | result["frameNum"] = self.frame_num 30 | if self.id is not None: 31 | result["_id"] = str(self.id) 32 | 33 | return result 34 | 35 | @staticmethod 36 | def from_json(json): 37 | """ 38 | Method to de-serialise a row from a JSON object 39 | :param json: the JSON object represented as dictionary 40 | :return: a representation of a row object 41 | """ 42 | result = Annotation() 43 | if "projectId" in json: 44 | result.project_id = json["projectId"] 45 | if "imageId" in json: 46 | result.image_id = json["imageId"] 47 | if "shapes" in json: 48 | result.shapes = json["shapes"] 49 | if "frameNum" in json: 50 | result.frame_num = json["frameNum"] 51 | if "_id" in json: 52 | result.id = str(json["_id"]) 53 | return result 54 | 55 | 56 | class AnnotationStore(AbstractMongoStore): 57 | """ 58 | Database store representing a MongoDB collection 59 | """ 60 | def __init__(self, mongo_url, mongo_db, collection_name): 61 | super().__init__(mongo_url, mongo_db, collection_name, Annotation, 1) 62 | 63 | def get_by_image(self, image_id, frame_num=None): 64 | if frame_num is None: 65 | return super().query({"imageId": image_id}) 66 | return super().query({"imageId": image_id, "frameNum": frame_num}) 67 | 68 | def get_by_project(self, project_id): 69 | return super().query({"projectId": project_id}) 70 | 71 | def get_by_annotation_type(self, project_id, annotation_type): 72 | return super().query({ 73 | "projectId": project_id, 74 | "shapes.annotationType": annotation_type, 75 | }) 76 | 77 | def remove_annotation_type(self, project_id, annotation_type): 78 | annotations = self.get_by_annotation_type(project_id, annotation_type) 79 | 80 | # function to remove annotation type 81 | def _remove_type(shape): 82 | if "annotationType" in shape and shape["annotationType"] == annotation_type: 83 | shape["annotationType"] = None 84 | return shape 85 | 86 | for annotation in annotations: 87 | self.update_one(AbstractMongoStore.id_query(annotation.id), 88 | AbstractMongoStore.set_update("shapes", list(map(_remove_type, annotation.shapes)))) 89 | 90 | def migrate_annotation_type(self, project_id, annotation_type, new_type): 91 | annotations = self.get_by_annotation_type(project_id, annotation_type) 92 | 93 | def _update_type(shape): 94 | if "annotationType" in shape and shape["annotationType"] == annotation_type: 95 | shape["annotationType"] = new_type 96 | return shape 97 | 98 | for annotation in annotations: 99 | self.update_one(AbstractMongoStore.id_query(annotation.id), 100 | AbstractMongoStore.set_update("shapes", list(map(_update_type, annotation.shapes)))) 101 | -------------------------------------------------------------------------------- /storage/image_store.py: -------------------------------------------------------------------------------- 1 | from pbumongo import AbstractMongoStore 2 | 3 | 4 | class Image: 5 | """ 6 | Object class representing a document in this database collection. 7 | """ 8 | 9 | def __init__(self): 10 | self.project_id = None 11 | self.id = None 12 | self.content_type = None 13 | self.width = 0 14 | self.height = 0 15 | self.num_frames = None 16 | self.label = None 17 | self.original_file_names = None 18 | 19 | def to_json(self): 20 | """ 21 | Serialises the current instance into JSON 22 | :return: a dictionary containing the fields and values of this instance 23 | """ 24 | result = { 25 | "width": self.width, 26 | "height": self.height, 27 | } 28 | if self.project_id is not None: 29 | result["projectId"] = self.project_id 30 | if self.content_type is not None: 31 | result["contentType"] = self.content_type 32 | if self.id is not None: 33 | result["_id"] = str(self.id) 34 | if self.num_frames is not None: 35 | result["numFrames"] = self.num_frames 36 | if self.label is not None: 37 | result["label"] = self.label 38 | if self.original_file_names is not None: 39 | result["originalFileNames"] = self.original_file_names 40 | 41 | return result 42 | 43 | @staticmethod 44 | def from_json(json): 45 | """ 46 | Method to de-serialise a row from a JSON object 47 | :param json: the JSON object represented as dictionary 48 | :return: a representation of a row object 49 | """ 50 | result = Image() 51 | if "projectId" in json: 52 | result.project_id = json["projectId"] 53 | if "width" in json: 54 | result.width = json["width"] 55 | if "height" in json: 56 | result.height = json["height"] 57 | if "contentType" in json: 58 | result.content_type = json["contentType"] 59 | if "numFrames" in json: 60 | result.num_frames = json["numFrames"] 61 | if "label" in json: 62 | result.label = json["label"] 63 | if "originalFileNames" in json: 64 | result.original_file_names = json["originalFileNames"] 65 | if "_id" in json: 66 | result.id = str(json["_id"]) 67 | return result 68 | 69 | 70 | class ImageStore(AbstractMongoStore): 71 | """ 72 | Database store representing a MongoDB collection 73 | """ 74 | def __init__(self, mongo_url, mongo_db, collection_name): 75 | super().__init__(mongo_url, mongo_db, collection_name, Image, 1) 76 | 77 | def get_by_project(self, project_id): 78 | return super().query({"projectId": project_id}) 79 | 80 | def update_dimension(self, image_id, width, height): 81 | return super().update_one(AbstractMongoStore.id_query(image_id), 82 | AbstractMongoStore.set_update(["width", "height"], [width, height])) 83 | 84 | def update_original_file_names(self, image_id, original_file_names): 85 | return super().update_one(AbstractMongoStore.id_query(image_id), 86 | AbstractMongoStore.set_update("originalFileNames", original_file_names)) 87 | 88 | def update_label(self, image_id, new_label): 89 | return super().update_one(AbstractMongoStore.id_query(image_id), 90 | AbstractMongoStore.set_update("label", new_label)) 91 | -------------------------------------------------------------------------------- /storage/project_store.py: -------------------------------------------------------------------------------- 1 | from pbumongo import AbstractMongoStore 2 | 3 | 4 | class Project: 5 | """ 6 | Object class representing a document in this database collection. 7 | """ 8 | 9 | def __init__(self): 10 | self.name = None 11 | self.id = None 12 | self.image_count = 0 13 | self.annotation_count = 0 14 | self.annotation_types = {} 15 | 16 | def to_json(self): 17 | """ 18 | Serialises the current instance into JSON 19 | :return: a dictionary containing the fields and values of this instance 20 | """ 21 | result = { 22 | "imageCount": self.image_count, 23 | "annotationCount": self.annotation_count, 24 | "annotationTypes": self.annotation_types, 25 | } 26 | if self.name is not None: 27 | result["name"] = self.name 28 | if self.id is not None: 29 | result["_id"] = str(self.id) 30 | return result 31 | 32 | @staticmethod 33 | def from_json(json): 34 | """ 35 | Method to de-serialise a row from a JSON object 36 | :param json: the JSON object represented as dictionary 37 | :return: a representation of a row object 38 | """ 39 | result = Project() 40 | if "name" in json: 41 | result.name = json["name"] 42 | if "imageCount" in json: 43 | result.image_count = json["imageCount"] 44 | if "annotationCount" in json: 45 | result.annotation_count = json["annotationCount"] 46 | if "annotationTypes" in json: 47 | result.annotation_types = json["annotationTypes"] 48 | if "_id" in json: 49 | result.id = str(json["_id"]) 50 | return result 51 | 52 | 53 | class ProjectStore(AbstractMongoStore): 54 | """ 55 | Database store representing a MongoDB collection 56 | """ 57 | 58 | def __init__(self, mongo_url, mongo_db, collection_name): 59 | super().__init__(mongo_url, mongo_db, collection_name, Project, 1) 60 | 61 | def update_counts(self, project): 62 | return super().update_one(AbstractMongoStore.id_query(project.id), 63 | AbstractMongoStore.set_update(["imageCount","annotationCount"], 64 | [project.image_count, project.annotation_count])) 65 | 66 | def update_annotation_types(self, project_id, annotation_types): 67 | return super().update_one(AbstractMongoStore.id_query(project_id), 68 | AbstractMongoStore.set_update("annotationTypes", annotation_types)) 69 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack") 2 | const HtmlWebpackPlugin = require("html-webpack-plugin") 3 | const path = require("path") 4 | 5 | const BUILD_DIR = path.resolve(__dirname, "static") 6 | const APP_DIR = path.resolve(__dirname, "frontend", "src") 7 | 8 | const HTMLWebpackPluginConfig = new HtmlWebpackPlugin({ 9 | template: "frontend/index.html", 10 | filename: "../templates/index.html", 11 | inject: true, 12 | }) 13 | 14 | // Enable multi-pass compilation for enhanced performance 15 | // in larger projects. Good default 16 | const HotModuleReplacementPluginConfig = new webpack.HotModuleReplacementPlugin({ 17 | multiStep: false, 18 | }) 19 | 20 | // See https://medium.com/@kimberleycook/intro-to-webpack-1d035a47028d#.8zivonmtp for 21 | // a step-by-step introduction to reading a webpack config 22 | const config = { 23 | entry: `${APP_DIR}/index.js`, 24 | output: { 25 | path: BUILD_DIR, 26 | filename: "index.js", 27 | publicPath: "/static", 28 | }, 29 | module: { 30 | rules: [ 31 | { 32 | test: /\.(js|jsx)$/, 33 | exclude: /node_modules/, 34 | use: ["babel-loader"], 35 | }, 36 | { 37 | test: /\.css$/, 38 | use: ["style-loader", "css-loader"], 39 | }, 40 | { 41 | test: /\.(png|jpg|jpeg)$/, 42 | use: [ 43 | { 44 | loader: "url-loader", 45 | options: { 46 | limit: 65536, 47 | }, 48 | }, 49 | ], 50 | }, 51 | ], 52 | }, 53 | plugins: [HTMLWebpackPluginConfig, HotModuleReplacementPluginConfig], 54 | } 55 | 56 | module.exports = config 57 | --------------------------------------------------------------------------------