├── .gitignore ├── AUTHORS ├── DEPENDENCIES ├── LICENSE ├── README.md ├── client ├── .eslintrc.js ├── .gitignore ├── Makefile ├── config-builder.js ├── package-lock.json ├── package.json ├── src │ ├── app │ │ ├── aligner.directive.js │ │ ├── aligner │ │ │ └── aligner.logic-handler.js │ │ ├── config.js │ │ ├── image-upload.directive.js │ │ ├── index.js │ │ ├── loading-widget.js │ │ ├── logic-handler.js │ │ ├── main.controller.js │ │ ├── modal.js │ │ ├── socket.js │ │ ├── utils.js │ │ ├── viewer.directive.js │ │ └── viewer │ │ │ ├── calibrator.js │ │ │ ├── camera.js │ │ │ ├── collision-tracker.js │ │ │ ├── event-handler.js │ │ │ ├── graphics │ │ │ ├── circle.js │ │ │ ├── fill.js │ │ │ ├── functions.js │ │ │ ├── graphics-object.js │ │ │ ├── line.js │ │ │ ├── opacity.js │ │ │ ├── rectangle.js │ │ │ └── stroke.js │ │ │ ├── keycodes.js │ │ │ ├── layer-manager.js │ │ │ ├── logic-handler.adjustment.js │ │ │ ├── rendering-client.js │ │ │ ├── scale-manager.js │ │ │ ├── spots.js │ │ │ ├── undo.js │ │ │ └── vec2.js │ ├── assets │ │ ├── css │ │ │ ├── aligner.css │ │ │ └── stylesheet.css │ │ ├── html │ │ │ ├── aligner.html │ │ │ ├── index.html │ │ │ ├── modal-button.html │ │ │ └── modal.html │ │ └── images │ │ │ ├── imageToggleCy3.png │ │ │ └── imageToggleHE.png │ └── worker │ │ ├── filters.js │ │ ├── index.js │ │ └── return-codes.js ├── webpack.config.devel.js └── webpack.config.dist.js ├── sample_images ├── 160906_SE_STtutorial_breast_D1_HE_AM_rotated180.jpg └── 160908_SE_STtutorial_Breast_D1_Cy3_AM_rotated180.jpg └── server ├── app.wsgi ├── app ├── .pylintrc ├── __init__.py ├── __main__.py ├── app.py ├── circle_detector.py ├── imageprocessor.py ├── logger.py ├── spots.py └── utils.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.pyc 3 | cv2 4 | blobdetection.py 5 | imgtest/ 6 | __pycache__/ 7 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Kim Wong 2 | Jose Fernandez 3 | Ludvig Bergenstråhle 4 | -------------------------------------------------------------------------------- /DEPENDENCIES: -------------------------------------------------------------------------------- 1 | ------------------------------------------------------------------------------- 2 | 3 | Bootstrap 4 | 5 | The MIT License (MIT) 6 | 7 | Copyright (c) 2011-2016 Twitter, Inc. 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in 17 | all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 25 | THE SOFTWARE. 26 | 27 | ------------------------------------------------------------------------------- 28 | 29 | jQuery 30 | 31 | Copyright jQuery Foundation and other contributors, https://jquery.org/ 32 | 33 | This software consists of voluntary contributions made by many 34 | individuals. For exact contribution history, see the revision history 35 | available at https://github.com/jquery/jquery 36 | 37 | The following license applies to all parts of this software except as 38 | documented below: 39 | 40 | ==== 41 | 42 | Permission is hereby granted, free of charge, to any person obtaining 43 | a copy of this software and associated documentation files (the 44 | "Software"), to deal in the Software without restriction, including 45 | without limitation the rights to use, copy, modify, merge, publish, 46 | distribute, sublicense, and/or sell copies of the Software, and to 47 | permit persons to whom the Software is furnished to do so, subject to 48 | the following conditions: 49 | 50 | The above copyright notice and this permission notice shall be 51 | included in all copies or substantial portions of the Software. 52 | 53 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 54 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 55 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 56 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 57 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 58 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 59 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 60 | 61 | ==== 62 | 63 | All files located in the node_modules and external directories are 64 | externally maintained libraries used by this software which have their 65 | own licenses; we recommend you read them, as their terms may differ from 66 | the terms above. 67 | 68 | ------------------------------------------------------------------------------- 69 | 70 | AngularJS 71 | 72 | The MIT License 73 | 74 | Copyright (c) 2010-2016 Google, Inc. http://angularjs.org 75 | 76 | Permission is hereby granted, free of charge, to any person obtaining a copy 77 | of this software and associated documentation files (the "Software"), to deal 78 | in the Software without restriction, including without limitation the rights 79 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 80 | copies of the Software, and to permit persons to whom the Software is 81 | furnished to do so, subject to the following conditions: 82 | 83 | The above copyright notice and this permission notice shall be included in 84 | all copies or substantial portions of the Software. 85 | 86 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 87 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 88 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 89 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 90 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 91 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 92 | THE SOFTWARE. 93 | 94 | ------------------------------------------------------------------------------- 95 | 96 | Spin Kit 97 | 98 | The MIT License (MIT) 99 | 100 | Copyright (c) 2015 Tobias Ahlin 101 | 102 | Permission is hereby granted, free of charge, to any person obtaining a copy of 103 | this software and associated documentation files (the "Software"), to deal in 104 | the Software without restriction, including without limitation the rights to 105 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 106 | the Software, and to permit persons to whom the Software is furnished to do so, 107 | subject to the following conditions: 108 | 109 | The above copyright notice and this permission notice shall be included in all 110 | copies or substantial portions of the Software. 111 | 112 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 113 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 114 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 115 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 116 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 117 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 118 | 119 | ------------------------------------------------------------------------------- 120 | 121 | OpenCV 122 | 123 | By downloading, copying, installing or using the software you agree to this license. 124 | If you do not agree to this license, do not download, install, 125 | copy or use the software. 126 | 127 | 128 | License Agreement 129 | For Open Source Computer Vision Library 130 | (3-clause BSD License) 131 | 132 | Copyright (C) 2000-2015, Intel Corporation, all rights reserved. 133 | Copyright (C) 2009-2011, Willow Garage Inc., all rights reserved. 134 | Copyright (C) 2009-2015, NVIDIA Corporation, all rights reserved. 135 | Copyright (C) 2010-2013, Advanced Micro Devices, Inc., all rights reserved. 136 | Copyright (C) 2015, OpenCV Foundation, all rights reserved. 137 | Copyright (C) 2015, Itseez Inc., all rights reserved. 138 | Third party copyrights are property of their respective owners. 139 | 140 | Redistribution and use in source and binary forms, with or without modification, 141 | are permitted provided that the following conditions are met: 142 | 143 | * Redistributions of source code must retain the above copyright notice, 144 | this list of conditions and the following disclaimer. 145 | 146 | * Redistributions in binary form must reproduce the above copyright notice, 147 | this list of conditions and the following disclaimer in the documentation 148 | and/or other materials provided with the distribution. 149 | 150 | * Neither the names of the copyright holders nor the names of the contributors 151 | may be used to endorse or promote products derived from this software 152 | without specific prior written permission. 153 | 154 | This software is provided by the copyright holders and contributors "as is" and 155 | any express or implied warranties, including, but not limited to, the implied 156 | warranties of merchantability and fitness for a particular purpose are disclaimed. 157 | In no event shall copyright holders or contributors be liable for any direct, 158 | indirect, incidental, special, exemplary, or consequential damages 159 | (including, but not limited to, procurement of substitute goods or services; 160 | loss of use, data, or profits; or business interruption) however caused 161 | and on any theory of liability, whether in contract, strict liability, 162 | or tort (including negligence or otherwise) arising in any way out of 163 | the use of this software, even if advised of the possibility of such damage. 164 | 165 | ------------------------------------------------------------------------------- 166 | 167 | Pillow 168 | 169 | The Python Imaging Library (PIL) is 170 | 171 | Copyright © 1997-2011 by Secret Labs AB 172 | Copyright © 1995-2011 by Fredrik Lundh 173 | 174 | Pillow is the friendly PIL fork. It is 175 | 176 | Copyright © 2016 by Alex Clark and contributors 177 | 178 | Like PIL, Pillow is licensed under the MIT-like open source PIL Software 179 | License: 180 | 181 | By obtaining, using, and/or copying this software and/or its associated 182 | documentation, you agree that you have read, understood, and will comply with 183 | the following terms and conditions: 184 | 185 | Permission to use, copy, modify, and distribute this software and its 186 | associated documentation for any purpose and without fee is hereby granted, 187 | provided that the above copyright notice appears in all copies, and that both 188 | that copyright notice and this permission notice appear in supporting 189 | documentation, and that the name of Secret Labs AB or the author not be used 190 | in advertising or publicity pertaining to distribution of the software without 191 | specific, written prior permission. 192 | 193 | SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS 194 | SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN 195 | NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR ANY SPECIAL, 196 | INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 197 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 198 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 199 | PERFORMANCE OF THIS SOFTWARE. 200 | 201 | ------------------------------------------------------------------------------- 202 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Kim Wong , 3 | KTH Royal Institute of Technology, Stockholm, Sweden. 4 | All rights reserved. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the "Software"), 8 | to deal in the Software without restriction, including without limitation 9 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 10 | and/or sell copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ST Spot Detector 2 | A web tool for automatic spot detection and positional adjustments for ST datasets. 3 | 4 | The arrays used to generate ST datasets may contain positional variations due to printing artifacts. This web tool aims to detect correct spot positions using the images generated from the ST protocol. 5 | In order to obtain relevant experimental data, it is also possible to automatically select the spots which are located under the tissue, using a corresponding HE image. 6 | The spot positions and selections are further adjustable to one's own needs. 7 | A file is generated which contains the corrected spot coordinates of the ST data as adjusted array coordinates and pixel coordinates as well as file containing a 3x3 affine matrix to transform array coordinates to pixel coordinates which can be useful for downstream analysis. 8 | 9 | ## Dependencies 10 | The following packages are required: 11 | ``` 12 | Python 3.6+ 13 | Node.js 10.0+ 14 | npm 6.0+ 15 | ``` 16 | Additionally, the [ST tissue recognition library](https://github.com/SpatialTranscriptomicsResearch/st_tissue_recognition) needs to be installed. 17 | 18 | A modern browser is required for the front-end interface. 19 | The web app has been tested on Chrome 66 and Firefox 60. 20 | 21 | ## Usage 22 | If you want to deploy the ST Spot detector locally on your computer 23 | you can use this [singularity](https://github.com/SpatialTranscriptomicsResearch/st_spot_detector_singularity) 24 | container. Otherwise follow the deployment instructions below. 25 | 26 | ### Download 27 | 1. Clone the repository 28 | 29 | ``` 30 | git clone https://github.com/SpatialTranscriptomicsResearch/st_spot_detector.git 31 | ``` 32 | 2. Move into the directory 33 | ``` 34 | cd st_spot_detector 35 | ``` 36 | 37 | ### Server setup 38 | 1. Install the [dependencies](#dependencies) 39 | 40 | 2. Create and activate a Python virtual environment 41 | 42 | ``` 43 | pip install virtualenv 44 | virtualenv venv 45 | source venv/bin/activate 46 | ``` 47 | 48 | 3. Install the dependencies in requirements.txt. 49 | 50 | ``` 51 | cd server 52 | pip install -r requirements.txt 53 | ``` 54 | 55 | 4. Install the tissue recognition library (still within the Python virtual environment). Follow the instructions [here.](https://github.com/SpatialTranscriptomicsResearch/st_tissue_recognition) 56 | 57 | 5. Build the client side files: 58 | 59 | ``` 60 | cd ../client 61 | npm install 62 | make dist 63 | ``` 64 | 65 | 6. Run the server with the following command: 66 | ``` 67 | cd ../server 68 | python -m app 69 | ``` 70 | The server is WSGI-compatible and can, alternatively, be run with a WSGI 71 | server of your choice. 72 | 73 | 7. *Optional*. 74 | It may be desirable to configure port-forwarding to be able to access the web tool through port 80, e.g. using iptables: 75 | ``` 76 | sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -i eth0 -j DNAT --to 0.0.0.0:8080 77 | 78 | ``` 79 | 80 | For any queries or concerns, feel free to contact the authors at the addresses given below. 81 | 82 | ### Citation 83 | If you want to cite the ST Spot detector: 84 | 85 | ST Spot Detector: a web-based application for automatic spot and tissue detection for Spatial Transcriptomics image data sets 86 | DOI: 10.1093/bioinformatics/bty030 87 | 88 | ### Manual 89 | For a guide on using the ST spots detection tool, please refer to [this guide.](https://github.com/SpatialTranscriptomicsResearch/st_spot_detector/wiki/ST-Spot-Detector-Usage-Guide) 90 | 91 | ## License 92 | MIT (see LICENSE). 93 | 94 | ## Authors 95 | See AUTHORS. 96 | 97 | ## Contact 98 | Kim Wong 99 | 100 | Ludvig Bergenstråhle 101 | 102 | Jose Fernandez 103 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const config = { 4 | extends: 'airbnb', 5 | env: { 6 | browser: true, 7 | }, 8 | rules: { 9 | indent: ['error', 4], 10 | 'no-multi-spaces': [ 11 | 'error', 12 | { 13 | exceptions: { 14 | VariableDeclarator: true, 15 | ImportDeclaration: true, 16 | }, 17 | }, 18 | ], 19 | 'no-bitwise': 0, 20 | 'key-spacing': 0, 21 | }, 22 | settings: { 23 | 'import/resolver': { 24 | webpack: { 25 | config: path.resolve(__dirname, 'webpack.config.devel.js'), 26 | }, 27 | }, 28 | }, 29 | }; 30 | 31 | module.exports = config; 32 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .tern-project 2 | devel/* 3 | dist/* 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /client/Makefile: -------------------------------------------------------------------------------- 1 | SRC_DIR = src 2 | NODE_DIR = ./node_modules/.bin 3 | 4 | .PHONY: all clean 5 | 6 | all: dist devel docs 7 | 8 | devel: $(shell find $(SRC_DIR)) config-builder.js webpack.config.devel.js 9 | @echo "Compiling development build..." 10 | @$(NODE_DIR)/webpack --config ./webpack.config.devel.js && touch -cm ./devel 11 | 12 | dist: $(shell find $(SRC_DIR)) config-builder.js webpack.config.dist.js 13 | @echo "Compiling release build..." 14 | @$(NODE_DIR)/webpack --config ./webpack.config.dist.js && touch -cm ./dist 15 | 16 | docs: $(shell find $(SRC_DIR) | grep \.js$) 17 | @echo "Compiling docs..." 18 | @$(NODE_DIR)/jsdoc $(SRC_DIR)/* -d docs && touch -cm ./docs 19 | 20 | clean: 21 | @if [ -d devel ]; then echo "Removing ./devel"; rm -r devel; fi 22 | @if [ -d dist ]; then echo "Removing ./dist" ; rm -r dist ; fi 23 | @if [ -d docs ]; then echo "Removing ./docs" ; rm -r docs ; fi 24 | -------------------------------------------------------------------------------- /client/config-builder.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const DynamicCdnWebpackPlugin = require('dynamic-cdn-webpack-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 7 | 8 | function configBuilder(deploy = false) { 9 | const config = { 10 | context: __dirname, 11 | entry: { 12 | app: ['babel-polyfill', path.resolve(__dirname, 'src/app')], 13 | worker: ['babel-polyfill', path.resolve(__dirname, 'src/worker/index.js')], 14 | }, 15 | output: { 16 | filename: '[name].js', 17 | }, 18 | resolve: { 19 | alias: { 20 | app: path.resolve(__dirname, 'src/app'), 21 | assets: path.resolve(__dirname, 'src/assets'), 22 | worker: path.resolve(__dirname, 'src/worker'), 23 | }, 24 | }, 25 | module: { 26 | rules: [{ 27 | test: /\.js$/, 28 | include: path.resolve(__dirname, 'src/'), 29 | use: [{ 30 | loader: 'babel-loader', 31 | options: { 32 | presets: ['env'], 33 | cacheDirectory: true, 34 | }, 35 | }], 36 | }, 37 | { 38 | test: /\.html$/, 39 | use: [{ 40 | loader: 'html-loader', 41 | options: { 42 | minimize: true, 43 | }, 44 | }], 45 | }, 46 | { 47 | test: /\.css$/, 48 | use: [ 49 | { 50 | loader: 'style-loader', 51 | }, 52 | { 53 | loader: 'css-loader', 54 | options: { 55 | minimize: true, 56 | }, 57 | }, 58 | ], 59 | }, 60 | { 61 | test: /\.(png|svg|jpg|gif)$/, 62 | use: [{ 63 | loader: 'file-loader', 64 | options: { 65 | outputPath: 'img/', 66 | }, 67 | }], 68 | }, 69 | { 70 | test: /\.(woff|woff2|eot|ttf|otf)$/, 71 | use: [{ 72 | loader: 'file-loader', 73 | options: { 74 | outputPath: 'fnt/', 75 | }, 76 | }], 77 | }, 78 | ], 79 | }, 80 | plugins: [ 81 | new HtmlWebpackPlugin({ 82 | excludeChunks: ['worker'], 83 | template: 'src/assets/html/index.html', 84 | }), 85 | new webpack.ProvidePlugin({ 86 | // jquery object must be global or bootstrap won't be able to find it 87 | $: 'jquery', 88 | jQuery: 'jquery', 89 | }), 90 | ], 91 | }; 92 | 93 | if (deploy) { 94 | config.output.path = path.join(__dirname, 'dist/'); 95 | 96 | config.plugins.push( 97 | new DynamicCdnWebpackPlugin({ 98 | only: [ 99 | 'angular', 100 | 'bootstrap', 101 | 'jquery', 102 | 'mathjs', 103 | 'sortablejs', 104 | 'lodash', 105 | ], 106 | }), 107 | new UglifyJsPlugin(), 108 | ); 109 | } else { // if (!deploy) 110 | config.output.path = path.join(__dirname, 'devel/'); 111 | 112 | config.devtool = 'source-map'; 113 | config.output.sourceMapFilename = '[name].js.map'; 114 | } 115 | 116 | return config; 117 | } 118 | 119 | module.exports = configBuilder; 120 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "babel-core": "^6.26.0", 4 | "babel-loader": "^7.1.2", 5 | "babel-polyfill": "^6.26.0", 6 | "babel-preset-env": "^1.6.1", 7 | "css-loader": "^0.28.7", 8 | "dynamic-cdn-webpack-plugin": "^3.4.1", 9 | "eslint": "^4.12.1", 10 | "eslint-config-airbnb": "^16.1.0", 11 | "eslint-config-airbnb-base": "^12.1.0", 12 | "eslint-import-resolver-webpack": "^0.8.3", 13 | "eslint-plugin-import": "^2.8.0", 14 | "eslint-plugin-jsx-a11y": "^6.0.2", 15 | "eslint-plugin-react": "^7.5.1", 16 | "file-loader": "^1.1.5", 17 | "html-loader": "^0.5.1", 18 | "html-webpack-plugin": "^2.30.1", 19 | "jsdoc": "^3.5.5", 20 | "module-to-cdn": "^3.1.1", 21 | "style-loader": "^0.19.0", 22 | "uglifyjs-webpack-plugin": "^1.1.2", 23 | "webpack": "^3.9.0" 24 | }, 25 | "dependencies": { 26 | "angular": "^1.6.7", 27 | "bootstrap": "^4.0.0-beta.2", 28 | "font-awesome": "^4.7.0", 29 | "jquery": "^3.2.1", 30 | "lodash": "^4.17.10", 31 | "mathjs": "^3.17.0", 32 | "popper.js": "^1.12.9", 33 | "sortablejs": "^1.7.0", 34 | "spinkit": "^1.2.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/src/app/aligner.directive.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module aligner.directive 3 | * 4 | * Directive for the alignment/editor view. 5 | */ 6 | 7 | import 'assets/css/aligner.css'; 8 | import template from 'assets/html/aligner.html'; 9 | 10 | import _ from 'lodash'; 11 | import sortable from 'sortablejs'; 12 | 13 | import { 14 | AlignerLHMove, 15 | AlignerLHRotate, 16 | } from './aligner/aligner.logic-handler'; 17 | import { 18 | ROT_POINT_COLOR_DEF, 19 | ROT_POINT_COLOR_HLT, 20 | ROT_POINT_LINE_WGHT, 21 | ROT_POINT_RADIUS, 22 | } from './config'; 23 | import { LogicHandlerWrapper } from './logic-handler'; 24 | import { StrokedCircle } from './viewer/graphics/circle'; 25 | import { collides } from './viewer/graphics/functions'; 26 | 27 | class State { 28 | constructor( 29 | active = null, 30 | adjustments = [], 31 | logicHandler = null, 32 | tool = null, 33 | ) { 34 | this.active = active; 35 | this.adjustments = adjustments; 36 | this.logicHandler = logicHandler; 37 | this.tool = tool; 38 | } 39 | 40 | clone() { 41 | return new State( 42 | this.active, 43 | new Map(_.map( 44 | Array.from(this.adjustments), 45 | ([k, xs]) => [k, _.clone(xs)], 46 | )), 47 | this.logicHandler, 48 | this.tool, 49 | ); 50 | } 51 | } 52 | 53 | const sgraphic = Symbol('Rotation point graphics object'); 54 | 55 | class RotationPoint { 56 | constructor(x, y) { 57 | this[sgraphic] = new StrokedCircle( 58 | x, y, 59 | ROT_POINT_RADIUS, 60 | { 61 | lineColor: ROT_POINT_COLOR_DEF, 62 | lineWidth: ROT_POINT_LINE_WGHT, 63 | }, 64 | ); 65 | } 66 | isHovering(x, y) { 67 | const ret = collides(x, y, this[sgraphic]); 68 | this[sgraphic].lineColor = 69 | ret ? ROT_POINT_COLOR_HLT : ROT_POINT_COLOR_DEF; 70 | return ret; 71 | } 72 | get x() { return this[sgraphic].x; } 73 | get y() { return this[sgraphic].y; } 74 | set x(v) { this[sgraphic].x = v; } 75 | set y(v) { this[sgraphic].y = v; } 76 | get graphic() { return this[sgraphic]; } 77 | } 78 | 79 | const ADJUSTMENTS = Object.freeze({ 80 | brightness: 0, 81 | contrast: 0, 82 | equalize: true, 83 | opacity: 1, 84 | }); 85 | 86 | const MULTIPLIERS = Object.freeze({ 87 | brightness: 3, 88 | contrast: 1, 89 | opacity: 1, 90 | }); 91 | 92 | function aligner() { 93 | return { 94 | link(scope, element) { 95 | /* eslint-disable no-param-reassign */ 96 | 97 | /* state management functions */ 98 | scope.state = new State(); 99 | 100 | function stateful(f) { 101 | return (...args) => { 102 | const state = scope.state.clone(); 103 | const [newState, value] = f(state, ...args); 104 | scope.state = newState; 105 | return value; 106 | }; 107 | } 108 | 109 | scope.logicHandler = new LogicHandlerWrapper(); 110 | 111 | // applies state to world 112 | scope.applyState = stateful((s) => { 113 | s.adjustments.forEach((xs, layer) => { 114 | scope.layers.getLayer(layer).adjustments = _.map( 115 | xs, 116 | ({ name, value }) => [ 117 | name, 118 | name in MULTIPLIERS 119 | ? value * MULTIPLIERS[name] 120 | : value, 121 | ], 122 | ); 123 | }); 124 | scope.logicHandler.set(s.logicHandler); 125 | scope.layers.callback(); 126 | return [s]; 127 | }); 128 | 129 | // reads state from world 130 | scope.readState = stateful((s) => { 131 | s.adjustments = new Map(_.map( 132 | _.map( 133 | scope.layers.layerOrder, 134 | x => [x, scope.layers.getLayer(x).adjustments], 135 | ), 136 | ([name, xs]) => [ 137 | name, 138 | _.map(xs, ([adjName, x]) => Object({ 139 | name: adjName, 140 | value: 141 | adjName in MULTIPLIERS 142 | ? x / MULTIPLIERS[adjName] 143 | : x, 144 | })), 145 | ], 146 | )); 147 | s.logicHandler = scope.logicHandler.logicHandler; 148 | return [s]; 149 | }); 150 | 151 | 152 | /* active layer */ 153 | scope.getActive = stateful(s => [s, s.active]); 154 | 155 | scope.setActive = stateful((s, name) => { 156 | if (name === undefined) { 157 | return [s]; 158 | } 159 | s.active = name; 160 | return [s]; 161 | }); 162 | 163 | scope.isActive = name => scope.getActive() === name; 164 | 165 | 166 | /* adjustments */ 167 | scope.addAdjustment = _.flowRight( 168 | scope.applyState, 169 | stateful((s, name, value, pos = -1) => { 170 | if (s.active !== undefined) { 171 | const adjs = s.adjustments.get(s.active); 172 | const n = adjs.length + 1; 173 | pos = ((pos % n) + n) % n; 174 | adjs.splice(pos, 0, { 175 | name, 176 | value: value || ADJUSTMENTS[name], 177 | }); 178 | } 179 | return [s]; 180 | }), 181 | ); 182 | 183 | scope.rmAdjustment = _.flowRight( 184 | scope.applyState, 185 | stateful((s, aObj) => { 186 | s.adjustments.set( 187 | s.active, 188 | _.filter( 189 | s.adjustments.get(s.active), 190 | x => x !== aObj, 191 | ), 192 | ); 193 | return [s]; 194 | }), 195 | ); 196 | 197 | 198 | /* tools */ 199 | const rotationPoint = new RotationPoint(0, 0); 200 | 201 | scope.tools = new Map([ 202 | ['move', { 203 | name: 'Move', 204 | logicHandler: new AlignerLHMove( 205 | scope.camera, 206 | () => scope.layers.getLayer(scope.state.active), 207 | () => scope.$apply(() => scope.layers.callback()), 208 | scope.undoStack, 209 | ), 210 | }], 211 | ['rotate', { 212 | name: 'Rotate', 213 | logicHandler: new AlignerLHRotate( 214 | scope.camera, 215 | () => scope.layers.getLayer(scope.state.active), 216 | () => scope.$apply(() => scope.layers.callback()), 217 | scope.undoStack, 218 | rotationPoint, 219 | ), 220 | }], 221 | ]); 222 | 223 | // Store tools in array for ng-repeat (ugh) 224 | scope.toolsArray = Array.from(scope.tools.entries()); 225 | 226 | scope.isCurrentTool = stateful((s, name) => [s, name === s.tool]); 227 | 228 | scope.setCurrentTool = _.flowRight( 229 | scope.applyState, 230 | stateful((s, name) => { 231 | if (name === 'rotate') { 232 | rotationPoint.x = scope.camera.position.x; 233 | rotationPoint.y = scope.camera.position.y; 234 | } 235 | s.logicHandler = scope.tools.get(name).logicHandler; 236 | s.tool = name; 237 | return [s]; 238 | }), 239 | ); 240 | 241 | 242 | /* init / exit */ 243 | scope.init = () => { 244 | scope.readState(); 245 | 246 | const n = scope.layers.layerOrder.length; 247 | _.each(_.zip(scope.layers.layerOrder, _.range(n)), ([x, i]) => { 248 | scope.setActive(x); 249 | scope.addAdjustment('opacity', (n - i) / n, 0); 250 | }); 251 | 252 | scope.setCurrentTool('move'); 253 | }; 254 | 255 | scope.exit = () => { 256 | // remove all opacity adjustments 257 | _.each( 258 | Array.from(scope.state.adjustments.entries()), 259 | ([x, ys]) => { 260 | scope.setActive(x); 261 | _.each( 262 | _.filter(ys, y => y.name === 'opacity'), 263 | (y) => { scope.rmAdjustment(y); }, 264 | ); 265 | }, 266 | ); 267 | scope.applyState(); 268 | }; 269 | 270 | 271 | /* other */ 272 | scope.renderables = () => { 273 | const ret = []; 274 | if (scope.state.tool === 'rotate') { 275 | ret.push(rotationPoint.graphic); 276 | } 277 | return ret; 278 | }; 279 | 280 | // make layer list sortable 281 | sortable.create(element[0].querySelector('#layer-list'), { 282 | onSort() { 283 | // update layer order 284 | _.each(this.toArray(), (val, i) => { 285 | scope.layers.layerOrder[i] = val; 286 | }); 287 | }, 288 | }); 289 | 290 | // make adjustments sortable 291 | sortable.create(element[0].querySelector('#adjustment-list'), { 292 | onSort({ oldIndex, newIndex }) { 293 | const adjustments = scope.state.adjustments.get(scope.state.active); 294 | const adjustment = adjustments.splice(oldIndex, 1)[0]; 295 | adjustments.splice(newIndex, 0, adjustment); 296 | scope.applyState(); 297 | }, 298 | handle: '.sort-handle', 299 | }); 300 | }, 301 | restrict: 'E', 302 | template, 303 | scope: { 304 | /* requires */ 305 | layers: '=', 306 | camera: '=', 307 | undoStack: '=', 308 | /* provides */ 309 | init: '=', 310 | exit: '=', 311 | logicHandler: '=', 312 | renderables: '=', 313 | }, 314 | }; 315 | } 316 | 317 | export default aligner; 318 | -------------------------------------------------------------------------------- /client/src/app/aligner/aligner.logic-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module aligner.logic-handler 3 | */ 4 | 5 | import _ from 'lodash'; 6 | import math from 'mathjs'; 7 | 8 | import Codes from '../viewer/keycodes'; 9 | import LogicHandler from '../logic-handler'; 10 | import Vec2 from '../viewer/vec2'; 11 | 12 | import { collides } from '../viewer/graphics/functions'; 13 | import { UndoAction } from '../viewer/undo'; 14 | import { setCursor } from '../utils'; 15 | 16 | // private members 17 | const curs = Symbol('Current state'); 18 | 19 | /** 20 | * Default logic handler for the alignment view. 21 | */ 22 | class AlignerLHDefault extends LogicHandler { 23 | constructor(camera, getActive, refreshFunc, undoStack) { 24 | super(); 25 | this.camera = camera; 26 | this.getActive = getActive; 27 | this.refresh = refreshFunc; 28 | this.undoStack = undoStack; 29 | this.recordKeyStates(); 30 | } 31 | 32 | processKeydownEvent() { 33 | this.refreshCursor(); 34 | } 35 | 36 | processKeyupEvent(e) { 37 | if (this.keystates.ctrl) { 38 | this.refreshCursor(); 39 | } else if (e === Codes.keyEvent.undo) { 40 | if(this.undoStack.lastTab() == "state_alignment") { 41 | var action = this.undoStack.pop(); 42 | const { layer, matrix } = action.state; 43 | layer.setTransform(matrix); 44 | this.refresh(); 45 | } 46 | } 47 | } 48 | 49 | processMouseEvent(e, data) { 50 | switch (e) { 51 | case Codes.mouseEvent.down: { 52 | const layer = this.getActive(); 53 | const action = new UndoAction( 54 | 'state_alignment', 55 | 'layerTransform', 56 | { layer, matrix: layer.getTransform() }, 57 | ); 58 | this.undoStack.setTemp(action); 59 | } break; 60 | case Codes.mouseEvent.up: 61 | if(this.undoStack.temp) { 62 | const layer = this.getActive(); 63 | const matrix = layer.getTransform(); 64 | const { layer: prevLayer, matrix: prevMatrix } = 65 | this.undoStack.temp.state; 66 | if (layer === prevLayer && !math.deepEqual(matrix, prevMatrix)) { 67 | this.undoStack.pushTemp(); 68 | } else { 69 | this.undoStack.clearTemp(); 70 | } 71 | } 72 | this.refresh(); 73 | break; 74 | case Codes.mouseEvent.drag: 75 | this.camera.pan(Vec2.Vec2( 76 | data.difference.x, 77 | data.difference.y, 78 | )); 79 | this.refresh(); 80 | break; 81 | case Codes.mouseEvent.wheel: 82 | this.camera.navigate(data.direction, data.position); 83 | this.refresh(); 84 | break; 85 | default: 86 | break; 87 | } 88 | this.refreshCursor(); 89 | } 90 | 91 | refreshCursor() { 92 | if (this.keystates.mouseLeft) { 93 | setCursor('grabbing'); 94 | } else { 95 | setCursor('grab'); 96 | } 97 | } 98 | } 99 | 100 | /** 101 | * Logic handler for the translation tool in the alignment view. 102 | */ 103 | class AlignerLHMove extends AlignerLHDefault { 104 | processKeydownEvent(e) { 105 | if (e === Codes.keyEvent.ctrl) { 106 | super.processKeydownEvent(e); 107 | } 108 | } 109 | 110 | processMouseEvent(e, data) { 111 | if (e === Codes.mouseEvent.wheel || data.ctrl) { 112 | super.processMouseEvent(e, data); 113 | return; 114 | } 115 | if (e === Codes.mouseEvent.drag) { 116 | this.getActive().translate( 117 | math.matrix([ 118 | [data.difference.x / this.camera.scale], 119 | [data.difference.y / this.camera.scale], 120 | ]), 121 | ); 122 | } else if (e === Codes.mouseEvent.up) { 123 | super.processMouseEvent(e, data); 124 | } else if (e === Codes.mouseEvent.down) { 125 | super.processMouseEvent(e, data); 126 | } 127 | this.refreshCursor(); 128 | } 129 | 130 | refreshCursor() { 131 | if (this.keystates.ctrl) { 132 | super.refreshCursor(); 133 | return; 134 | } 135 | if (this.keystates.mouseLeft || this.keystates.mouseRight) { 136 | setCursor('crosshair'); 137 | } else { 138 | setCursor('move'); 139 | } 140 | } 141 | } 142 | 143 | /** 144 | * Logic handler for the rotation tool in the alignment view. 145 | */ 146 | class AlignerLHRotate extends AlignerLHDefault { 147 | constructor(camera, layerManager, refreshFunc, undoStack, rotationPoint) { 148 | super(camera, layerManager, refreshFunc, undoStack); 149 | this.rp = rotationPoint; 150 | this.rpHoverState = false; 151 | this[curs] = 'def'; 152 | this.recordMousePosition(); 153 | this.recordKeyStates(); 154 | } 155 | 156 | processKeydownEvent(e) { 157 | if (this.keystates.ctrl) { 158 | super.processKeydownEvent(e); 159 | return; 160 | } 161 | this.refreshState(); 162 | } 163 | 164 | processKeyupEvent(e) { 165 | if (this.keystates.ctrl) { 166 | super.processKeyupEvent(e); 167 | return; 168 | } else if (e == Codes.keyEvent.undo) { 169 | super.processKeyupEvent(e); 170 | return; 171 | } 172 | this.refreshState(); 173 | } 174 | 175 | processMouseEvent(e, data) { 176 | if (this.keystates.ctrl || e === Codes.mouseEvent.wheel) { 177 | super.processMouseEvent(e, data); 178 | return; 179 | } 180 | switch (e) { 181 | case Codes.mouseEvent.drag: 182 | if (this[curs] === 'rotate') { 183 | const position = this.camera.mouseToCameraPosition(data.position); 184 | const difference = this.camera.mouseToCameraScale(data.difference); 185 | const to = Vec2.subtract(position, this.rp); 186 | const from = Vec2.subtract(to, difference); 187 | this.getActive().rotate( 188 | Vec2.angleBetween(from, to), 189 | math.matrix([ 190 | [this.rp.x], 191 | [this.rp.y], 192 | [1], 193 | ]), 194 | ); 195 | } else if (this[curs] === 'dragRP') { 196 | const rpNew = Vec2.subtract( 197 | this.rp, 198 | this.camera.mouseToCameraScale(data.difference), 199 | ); 200 | this.rp.x = rpNew.x; 201 | this.rp.y = rpNew.y; 202 | this.refresh(); 203 | } 204 | break; 205 | case Codes.mouseEvent.down: 206 | if (data.button === Codes.mouseButton.right) { 207 | const rpNew = this.camera.mouseToCameraPosition(data.position); 208 | this.rp.x = rpNew.x; 209 | this.rp.y = rpNew.y; 210 | this.refresh(); 211 | } 212 | else { 213 | super.processMouseEvent(e, data); 214 | } 215 | break; 216 | case Codes.mouseEvent.up: 217 | super.processMouseEvent(e, data); 218 | break; 219 | default: 220 | break; 221 | } 222 | this.refreshState(); 223 | } 224 | 225 | refreshState() { 226 | const { x, y } = this.camera.mouseToCameraPosition(this.mousePosition); 227 | if (this.rp.isHovering(x, y)) { 228 | if (this.keystates.mouseLeft !== true) { 229 | this[curs] = 'hoverRP'; 230 | } else if (this[curs] !== 'rotate') { 231 | this[curs] = 'dragRP'; 232 | } 233 | if (!this.rpHoverState) { 234 | this.rpHoverState = true; 235 | this.refresh(); 236 | } 237 | } else { 238 | if (this.keystates.mouseLeft === true) { 239 | this[curs] = 'rotate'; 240 | } else { 241 | this[curs] = 'def'; 242 | } 243 | if (this.rpHoverState) { 244 | this.rpHoverState = false; 245 | this.refresh(); 246 | } 247 | } 248 | this.refreshCursor(); 249 | } 250 | 251 | refreshCursor() { 252 | if (this.keystates.ctrl) { 253 | super.refreshCursor(); 254 | return; 255 | } 256 | switch (this[curs]) { 257 | case 'dragRP': 258 | setCursor('grabbing'); 259 | break; 260 | case 'hoverRP': 261 | setCursor('grab'); 262 | break; 263 | case 'rotate': 264 | setCursor('crosshair'); 265 | break; 266 | case 'def': 267 | default: 268 | setCursor('move'); 269 | break; 270 | } 271 | } 272 | } 273 | 274 | export { 275 | AlignerLHMove, 276 | AlignerLHRotate, 277 | }; 278 | -------------------------------------------------------------------------------- /client/src/app/config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | 3 | import _ from 'lodash'; 4 | 5 | // General 6 | export const MAX_CACHE_SIZE = 104857600; 7 | export const MAX_THREADS = 4; 8 | 9 | export const WORKER_PATH = 'worker.js'; 10 | 11 | 12 | // Options for the aligner 13 | export const ROT_POINT_COLOR_DEF = 'hsla(0, 80%, 50%, 0.6)'; 14 | export const ROT_POINT_COLOR_HLT = 'hsla(0, 100%, 80%, 1.0)'; 15 | export const ROT_POINT_LINE_WGHT = 7; 16 | export const ROT_POINT_RADIUS = 10; 17 | 18 | 19 | // Options for the adjustment view 20 | export const CALIBRATOR_LINE_COL_DEF = 'hsla(0, 0%, 100%, 0.3)'; 21 | export const CALIBRATOR_LINE_COL_HLT = 'hsla(0, 0%, 100%, 1.0)'; 22 | export const CALIBRATOR_LINE_WGHT = 4; 23 | export const CALIBRATOR_GRID_COL = 'hsla(0, 0%, 100%, 0.2)'; 24 | export const CALIBRATOR_GRID_WGHT = 3; 25 | export const CALIBRATOR_CORNER_COL = CALIBRATOR_LINE_COL_HLT; 26 | export const CALIBRATOR_CORNER_WGHT = 0.9 * CALIBRATOR_LINE_WGHT; 27 | 28 | export const COLLISION_LINE_DEF = 'white'; 29 | export const COLLISION_LINE_HLT = 'red'; 30 | export const COLLISION_LINE_WGHT = 3; 31 | 32 | export const SELECTION_RECT_COL = 'rgba(150, 150, 150, 0.95)'; 33 | export const SELECTION_RECT_DASH = [4, 3]; 34 | export const SELECTION_RECT_WGHT = 2; 35 | 36 | 37 | // Options for spot rendering 38 | export const SPOT_COLS = _.map( 39 | _.range(225, 225 + 360, 360 / 8), 40 | x => `hsl(${x}, 100%, 50%)`, 41 | ); 42 | export const SPOT_COL_DEF = _.first(SPOT_COLS); 43 | export const SPOT_COL_HLT = 'hsl(140, 100%, 50%)'; 44 | export const SPOT_OPACITIES = _.range(0, 1.2, 0.2); 45 | export const SPOT_OPACITY_DEF = SPOT_OPACITIES[ 46 | Math.trunc(SPOT_OPACITIES.length / 2)]; 47 | 48 | 49 | // Options for the loading widget 50 | export const LOADING_FPS = 60; 51 | export const LOADING_HIST_SIZE = 1000; 52 | export const LOADING_MEASURE_TIME = 5000; 53 | export const LOADING_TWEEN_TIME = 1000; 54 | -------------------------------------------------------------------------------- /client/src/app/image-upload.directive.js: -------------------------------------------------------------------------------- 1 | // this directive is bound to the input button on the menu bar and is used 2 | // to detect an image file being selected and converts the image to a string 3 | 4 | function imageUpload() { 5 | var link = function(scope, elem, attrs) { 6 | var cy3File = angular.element(elem[0].querySelector('#cy3-upload-filename')); 7 | var cy3Input = angular.element(elem[0].querySelector('#cy3-upload')); 8 | 9 | var heFile = angular.element(elem[0].querySelector('#he-upload-filename')); 10 | var heInput = angular.element(elem[0].querySelector('#he-upload')); 11 | 12 | // this is triggered when the state of the input button is changed; 13 | // e.g. it was empty but then a file has been selected 14 | cy3Input.bind('change', function(event) { 15 | // checks that it hasn't gone from file to empty 16 | if(event.target.files.length != 0) { 17 | var filename = event.target.files[0].name; 18 | scope.data.cy3Filename = filename; 19 | filename = shortenFilename(filename, 12); 20 | cy3File.text(filename); 21 | var img = event.target.files[0]; 22 | var reader = new FileReader(); 23 | 24 | // function which runs after loading 25 | reader.addEventListener('load', function() { 26 | scope.$apply(function() { 27 | scope.data.cy3Image = reader.result; 28 | }); 29 | }, false); 30 | if(img) { 31 | reader.readAsArrayBuffer(img); 32 | } 33 | } 34 | }); 35 | 36 | heInput.bind('change', function(event) { 37 | if(event.target.files.length != 0) { 38 | var filename = event.target.files[0].name; 39 | filename = shortenFilename(filename, 12); 40 | heFile.text(filename); 41 | var img = event.target.files[0]; 42 | var reader = new FileReader(); 43 | 44 | reader.addEventListener('load', function() { 45 | scope.$apply(function() { 46 | scope.data.heImage = reader.result; 47 | }); 48 | }, false); 49 | if(img) { 50 | reader.readAsArrayBuffer(img); 51 | } 52 | } 53 | }); 54 | 55 | /* from https://gist.github.com/solotimes/2537334 */ 56 | function shortenFilename(n, len) { 57 | if(len == undefined) len = 20; 58 | var ext = n.substring(n.lastIndexOf(".") + 1, n.length).toLowerCase(); 59 | var filename = n.replace('.' + ext,''); 60 | if(filename.length <= len) { 61 | return n; 62 | } 63 | filename = filename.substr(0, len) + (n.length > len ? '[...]' : ''); 64 | return filename + '.' + ext; 65 | }; 66 | }; 67 | return { 68 | restrict: 'A', 69 | scope: false, 70 | link: link 71 | }; 72 | } 73 | 74 | export default imageUpload; 75 | -------------------------------------------------------------------------------- /client/src/app/index.js: -------------------------------------------------------------------------------- 1 | import 'bootstrap/dist/js/bootstrap.bundle.min'; 2 | import 'bootstrap/dist/css/bootstrap.min.css'; 3 | import 'font-awesome/css/font-awesome.min.css'; 4 | import 'spinkit/css/spinners/10-fading-circle.css'; 5 | 6 | import 'assets/css/stylesheet.css'; 7 | import 'assets/html/index.html'; 8 | 9 | import $ from 'jquery'; 10 | import angular from 'angular'; 11 | 12 | import aligner from './aligner.directive'; 13 | import main from './main.controller'; 14 | import imageUpload from './image-upload.directive'; 15 | import viewer from './viewer.directive'; 16 | 17 | const app = angular.module('stSpots', []); 18 | 19 | app.controller('MainController', main); 20 | app.directive('imageUpload', imageUpload); 21 | app.directive('viewer', viewer); 22 | app.directive('aligner', aligner); 23 | 24 | $(document).ready(() => $('[data-toggle="tooltip"]').tooltip()); 25 | Notification.requestPermission(); 26 | -------------------------------------------------------------------------------- /client/src/app/loading-widget.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module aligner.directive 3 | * 4 | * Directive for the loading widget. 5 | */ 6 | 7 | import $ from 'jquery'; 8 | import _ from 'lodash'; 9 | 10 | import { 11 | LOADING_FPS as FPS, 12 | LOADING_HIST_SIZE as HIST_SIZE, 13 | LOADING_MEASURE_TIME as MEASURE_TIME, 14 | LOADING_TWEEN_TIME as TWEEN_TIME, 15 | } from './config'; 16 | 17 | const STAGE = Object.freeze({ 18 | INIT: 0, 19 | UPLOAD: 1, 20 | WAIT: 2, 21 | DOWNLOAD: 3, 22 | }); 23 | 24 | async function animate(initialScene, cancel, animators, render) { 25 | const stepTime = 1000 / FPS; 26 | const step = async (prevScene, prevTime) => { 27 | if (cancel()) { 28 | return undefined; 29 | } 30 | const nextTime = prevTime + stepTime; 31 | const timeLeft = nextTime - Date.now(); 32 | if (timeLeft > 0) { 33 | await new Promise(r => setTimeout(r, timeLeft)); 34 | } 35 | const newScene = animators.reduce( 36 | (a, x) => x(a, prevTime, nextTime), 37 | prevScene, 38 | ); 39 | render(newScene); 40 | return step(newScene, nextTime); 41 | }; 42 | return step(initialScene, Date.now()); 43 | } 44 | 45 | function getRenderer(canvas) { 46 | const ctx = canvas.getContext('2d'); 47 | const coords = (x, y) => [canvas.width * x, canvas.height * y]; 48 | 49 | const updateResolution = () => { 50 | /* eslint-disable no-param-reassign */ 51 | canvas.width = $(canvas).width(); 52 | canvas.height = $(canvas).height(); 53 | }; 54 | 55 | return ({ title, text, uploadProgress, downloadProgress }) => { 56 | updateResolution(); 57 | 58 | ctx.clearRect(0, 0, ...coords(1, 1)); 59 | 60 | ctx.textAlign = 'left'; 61 | ctx.font = '1.0em bold sans'; 62 | ctx.fillText(title, ...coords(0.1, 0.4)); 63 | 64 | ctx.textAlign = 'right'; 65 | ctx.font = '0.9em monospace'; 66 | ctx.fillText(text, ...coords(0.9, 0.4)); 67 | 68 | ctx.beginPath(); 69 | ctx.moveTo(...coords(0.1, 0.75)); 70 | ctx.lineTo(...coords(0.9, 0.75)); 71 | ctx.strokeStyle = '#fff'; 72 | ctx.lineWidth = 7; 73 | ctx.lineCap = 'round'; 74 | ctx.stroke(); 75 | 76 | ctx.beginPath(); 77 | ctx.moveTo(...coords(0.9, 0.75)); 78 | ctx.lineTo(...coords(0.9 - (0.8 * uploadProgress), 0.75)); 79 | ctx.strokeStyle = '#007bff'; 80 | ctx.lineWidth = 7; 81 | ctx.lineCap = 'round'; 82 | ctx.stroke(); 83 | 84 | if (downloadProgress > 0) { 85 | ctx.beginPath(); 86 | ctx.moveTo(...coords(0.1, 0.75)); 87 | ctx.lineTo(...coords(0.1 + (0.8 * downloadProgress), 0.75)); 88 | ctx.strokeStyle = '#fff'; 89 | ctx.lineWidth = 3; 90 | ctx.lineCap = 'round'; 91 | ctx.stroke(); 92 | } 93 | }; 94 | } 95 | 96 | function getAnimators(state) { 97 | const fmtBytes = (n, [x, ...xs] = ['B', 'kB', 'MB']) => { 98 | if (xs.length === 0 || n < 1000) { 99 | return [n, x]; 100 | } 101 | return fmtBytes(n / 1000, xs); 102 | }; 103 | 104 | const fmtTime = (n) => { 105 | const m = Math.floor(n / 60); 106 | return [m, n - (60 * m)]; 107 | }; 108 | 109 | const computeRate = () => { 110 | const lagged = _.first(_.map( 111 | _.takeRightWhile( 112 | state.history, 113 | s => s.stage === state.stage && 114 | s.timestamp > state.timestamp - MEASURE_TIME, 115 | ), 116 | s => [s.timestamp, s.loaded], 117 | )); 118 | if (lagged) { 119 | const [laggedTime, laggedLoaded] = lagged; 120 | return (state.loaded - laggedLoaded) * 121 | (1000 / (state.timestamp - laggedTime)); 122 | } 123 | return 0; 124 | }; 125 | 126 | const showRateInfo = () => { 127 | const rate = computeRate(); 128 | const remaining = state.total - state.loaded; 129 | const [fmtRate, unit] = fmtBytes(rate); 130 | const [m, s] = fmtTime(remaining / rate); 131 | return [ 132 | `${fmtRate.toFixed(2)} ${unit}/s`.padStart(11), 133 | `${m}:${`${Math.round(s)}`.padStart(2, '0')} left`, 134 | ].join(', '); 135 | }; 136 | 137 | return [ 138 | /* text animator */ 139 | (scene) => { 140 | const title = (() => { 141 | switch (state.stage) { 142 | case STAGE.UPLOAD: 143 | return `${state.title}: Uploading`; 144 | case STAGE.DOWNLOAD: 145 | return `${state.title}: Downloading`; 146 | default: 147 | return `${state.title}: ${state.message}`; 148 | } 149 | })(); 150 | const text = (() => { 151 | switch (state.stage) { 152 | case STAGE.UPLOAD: 153 | case STAGE.DOWNLOAD: 154 | return showRateInfo(); 155 | default: { 156 | const n = Math.trunc((Date.now() / 500)) % 20; 157 | const m = n > 10 ? 20 - n : n; 158 | return `[${' '.repeat(m)}○${' '.repeat(10 - m)}]`; 159 | } 160 | } 161 | })(); 162 | return Object.assign(scene, { title, text }); 163 | }, 164 | 165 | /* progress bar animator */ 166 | (scene, prevTime, time) => { 167 | const { uploadProgress, downloadProgress } = scene; 168 | const dt = Math.min((time - state.timestamp) / TWEEN_TIME, 1); 169 | const tweenFnc = _.curry((x, y) => x + ((y - x) * (1 - ((1 - dt) ** 2)))); 170 | const tweenFncPartial = tweenFnc(_, state.loaded / Math.max(1, state.total)); 171 | return Object.assign(scene, { 172 | uploadProgress: state.stage === STAGE.UPLOAD 173 | ? tweenFncPartial(uploadProgress) 174 | : state.stage > STAGE.UPLOAD, 175 | downloadProgress: state.stage === STAGE.DOWNLOAD 176 | ? tweenFncPartial(downloadProgress) 177 | : state.stage > STAGE.DOWNLOAD, 178 | }); 179 | }, 180 | ]; 181 | } 182 | 183 | class State { 184 | constructor({ canvas, title }) { 185 | this.title = title; 186 | this.canvas = canvas; 187 | this.stopped = false; 188 | this.stage = STAGE.INIT; 189 | this.message = 'Please wait...'; 190 | this.loaded = 0; 191 | this.total = 1; 192 | this.timestamp = Date.now(); 193 | this.animator = animate( 194 | { 195 | title: '', 196 | text: '', 197 | uploadProgress: 0, 198 | downloadProgress: 0, 199 | }, 200 | () => this.stopped, 201 | getAnimators(this), 202 | getRenderer(this.canvas), 203 | ); 204 | this.history = []; 205 | } 206 | } 207 | 208 | function create(element, title) { 209 | const canvas = $(''); 210 | return [ 211 | new State({ canvas: canvas[0], title }), 212 | canvas, 213 | ]; 214 | } 215 | 216 | function update(widget, data) { 217 | const { stage } = data; 218 | const time = Date.now(); 219 | 220 | const oldState = _.clone(widget); 221 | widget.history = _.clone(widget.history); 222 | widget.history.push(oldState); 223 | if (widget.history.length > HIST_SIZE) { 224 | widget.history.shift(); 225 | } 226 | widget.timestamp = time; 227 | widget.stage = stage; 228 | 229 | switch (stage) { 230 | case STAGE.UPLOAD: 231 | case STAGE.DOWNLOAD: 232 | widget.loaded = data.loaded; 233 | widget.total = data.total; 234 | break; 235 | case STAGE.WAIT: 236 | widget.message = data.message; 237 | break; 238 | default: 239 | break; 240 | } 241 | 242 | return widget; 243 | } 244 | 245 | async function stop(widget) { 246 | widget.stopped = true; 247 | await widget.animator; 248 | } 249 | 250 | export { create, update, stop }; 251 | export { STAGE as LOAD_STAGE }; 252 | -------------------------------------------------------------------------------- /client/src/app/logic-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module logic-handler 3 | */ 4 | 5 | import Codes from './viewer/keycodes'; 6 | import Vec2 from './viewer/vec2'; 7 | 8 | /** 9 | * Abstract class that defines a unified interface for the event handler across 10 | * different logic handlers. 11 | */ 12 | /* eslint-disable no-unused-vars, class-methods-use-this */ 13 | // (makes sense to disable these, since this class is abstract */ 14 | class LogicHandler { 15 | constructor() { 16 | if (new.target === LogicHandler) { 17 | throw new TypeError( 18 | "Call of new on abstract class LogicHandler not allowed." 19 | ); 20 | } 21 | } 22 | 23 | /** 24 | * Abstract method for handling key down events. 25 | * 26 | * @param {Object} e - The event object 27 | */ 28 | processKeydownEvent(e) { 29 | throw new Error('Abstract method not implemented.'); 30 | } 31 | 32 | /** 33 | * Abstract method for handling key up events. 34 | * 35 | * @param {Object} e - The event object 36 | */ 37 | processKeyupEvent(e) { 38 | throw new Error('Abstract method not implemented.'); 39 | } 40 | 41 | /** 42 | * Abstract method for handling mouse events. 43 | * 44 | * @param {Object} e - The event object 45 | */ 46 | processMouseEvent(e, data) { 47 | throw new Error('Abstract method not implemented.'); 48 | } 49 | 50 | /** 51 | * Wraps {@link LogicHandler#processMouseEvent} to record the current mouse 52 | * position. The mouse position is stored in {@link 53 | * LogicHandler#mousePosition}. This may be useful when the logic handler 54 | * needs to check the mouse position on a key up/down event. 55 | */ 56 | recordMousePosition() { 57 | /** 58 | * @type {Vec2} 59 | */ 60 | this.mousePosition = Vec2.Vec2(0, 0); 61 | 62 | const oldME = this.processMouseEvent; 63 | function constructNewME() { 64 | function ret(e, data) { 65 | this.mousePosition = data.position; 66 | return oldME.apply(this, [e, data]); 67 | } 68 | return ret; 69 | } 70 | this.processMouseEvent = constructNewME(); 71 | } 72 | 73 | /** 74 | * Wraps {@link LogicHandler#processKeydownEvent}, {@link 75 | * LogicHandler#processKeyupEvent}, and {@link 76 | * LogicHandler#processMouseEvent} to record the current key states. The key 77 | * states are stored in {@link LogicHandler#_keystates}. 78 | */ 79 | recordKeyStates() { 80 | const keys = {}; 81 | const codeToKey = {}; 82 | Object.keys(Codes.keys).forEach((k) => { 83 | keys[k] = false; 84 | codeToKey[Codes.keyEvent[k]] = k; 85 | }); 86 | keys.mouseLeft = false; 87 | keys.mouseRight = false; 88 | 89 | /** 90 | * @type {Object} 91 | */ 92 | this.keystates = Object.seal(keys); 93 | 94 | const oldKDE = this.processKeydownEvent; 95 | function newKDE(e) { 96 | if (codeToKey[e] in keys) { 97 | keys[codeToKey[e]] = true; 98 | } 99 | return oldKDE.apply(this, [e]); 100 | } 101 | this.processKeydownEvent = newKDE; 102 | 103 | const oldKUE = this.processKeyupEvent; 104 | function newKUE(e) { 105 | if (codeToKey[e] in keys) { 106 | keys[codeToKey[e]] = false; 107 | } 108 | return oldKUE.apply(this, [e]); 109 | } 110 | this.processKeyupEvent = newKUE; 111 | 112 | const oldME = this.processMouseEvent; 113 | function newME(e, data) { 114 | if (e === Codes.mouseEvent.down) { 115 | switch (data.button) { 116 | case Codes.mouseButton.left: 117 | keys.mouseLeft = true; 118 | break; 119 | case Codes.mouseButton.right: 120 | keys.mouseRight = true; 121 | break; 122 | default: 123 | break; 124 | } 125 | } else if (e === Codes.mouseEvent.up) { 126 | switch (data.button) { 127 | case Codes.mouseButton.left: 128 | keys.mouseLeft = false; 129 | break; 130 | case Codes.mouseButton.right: 131 | keys.mouseRight = false; 132 | break; 133 | default: 134 | break; 135 | } 136 | } 137 | return oldME.apply(this, [e, data]); 138 | } 139 | this.processMouseEvent = newME; 140 | } 141 | } 142 | 143 | /** 144 | * Simple wrapper for the LogicHandler class. Allows changing the logic handler without 145 | * reassignment. 146 | */ 147 | class LogicHandlerWrapper extends LogicHandler { 148 | constructor(logicHandler) { 149 | super(); 150 | this.set(logicHandler); 151 | } 152 | 153 | set(logicHandler) { 154 | this.logicHandler = logicHandler; 155 | } 156 | 157 | processKeydownEvent(e) { 158 | this.logicHandler.processKeydownEvent(e); 159 | } 160 | 161 | processKeyupEvent(e) { 162 | this.logicHandler.processKeyupEvent(e); 163 | } 164 | 165 | processMouseEvent(e, data) { 166 | this.logicHandler.processMouseEvent(e, data); 167 | } 168 | } 169 | 170 | export default LogicHandler; 171 | export { LogicHandlerWrapper }; 172 | -------------------------------------------------------------------------------- /client/src/app/modal.js: -------------------------------------------------------------------------------- 1 | import htmlModal from 'assets/html/modal.html'; 2 | import htmlButton from 'assets/html/modal-button.html'; 3 | 4 | import 'bootstrap/js/src/modal'; 5 | 6 | import $ from 'jquery'; 7 | import _ from 'lodash'; 8 | 9 | function spawnModal( 10 | title, 11 | contents, 12 | { 13 | buttons = [['Close', _.noop]], 14 | container = document.body, 15 | } = {}, 16 | ) { 17 | const modal = $(htmlModal).prependTo(container); 18 | modal.find('.modal-title').html(title); 19 | modal.find('.modal-body').html(contents); 20 | const footer = modal.find('.modal-footer'); 21 | _.each( 22 | _.map( 23 | buttons, 24 | ([text, action]) => $(htmlButton).html(text).click(action), 25 | ), 26 | x => footer.append(x), 27 | ); 28 | modal.on('hidden.bs.modal', () => modal.remove()); 29 | modal.modal(); 30 | } 31 | 32 | function error(message, kwargs = {}) { 33 | spawnModal('Error', message === null ? 'Unkown error' : message, kwargs); 34 | } 35 | 36 | function warning(message, kwargs = {}) { 37 | spawnModal('Warning', message === null ? 'Unkown warning' : message, kwargs); 38 | } 39 | 40 | export default spawnModal; 41 | export { error, warning }; 42 | -------------------------------------------------------------------------------- /client/src/app/socket.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | const STATUS = Object.freeze({ 4 | SENDING: 1 << 0, 5 | PROCESSING: 1 << 1, 6 | RECEIVING: 1 << 2, 7 | }); 8 | 9 | function send(url, items, callback) { 10 | return new Promise((resolve, reject) => { 11 | /* eslint-disable no-await-in-loop */ 12 | const socket = new WebSocket(url); 13 | 14 | socket.onopen = async () => { 15 | /* eslint-disable no-restricted-syntax */ 16 | for (const { type, identifier, data } of items) { 17 | const packages = [`${type}:${identifier}:1`, data]; 18 | const uploadSize = _.sum(_.map(packages, x => x.length || x.byteLength)); 19 | _.each(packages, (x) => { socket.send(x); }); 20 | while (socket.readyState < socket.CLOSED) { 21 | callback([ 22 | STATUS.SENDING, 23 | { 24 | identifier, 25 | loaded: uploadSize - socket.bufferedAmount, 26 | total: uploadSize, 27 | }, 28 | ]); 29 | if (!(socket.bufferedAmount > 0)) { 30 | break; 31 | } 32 | await new Promise(r => setTimeout(r, 100)); 33 | } 34 | } 35 | socket.send('\0'); 36 | }; 37 | 38 | let error; 39 | const response = {}; 40 | const process = (type, identifier, data) => { 41 | switch (type) { 42 | case 'error': 43 | error = data; 44 | break; 45 | case 'json': 46 | response[identifier] = JSON.parse(data); 47 | break; 48 | case 'status': 49 | callback([ 50 | STATUS.PROCESSING, 51 | { message: data }, 52 | ]); 53 | break; 54 | default: 55 | // ignore 56 | } 57 | }; 58 | 59 | let type = String(); 60 | let identifier = String(); 61 | let chunks = 0; 62 | let total = 0; 63 | let data = ''; 64 | socket.onmessage = ({ data: x }) => { 65 | if (x === '\0') { 66 | socket.send(''); 67 | return; 68 | } 69 | if (chunks === 0) { 70 | data = ''; 71 | [type, identifier, chunks, total] = x.split(':'); 72 | total = parseInt(total, 10); 73 | } else { 74 | chunks -= 1; 75 | data += x; 76 | if (chunks === 0) { 77 | process(type, identifier, data); 78 | } 79 | } 80 | if (type !== 'status') { 81 | callback([ 82 | STATUS.RECEIVING, 83 | { 84 | identifier, 85 | loaded: data.length, 86 | total, 87 | }, 88 | ]); 89 | } 90 | }; 91 | 92 | socket.onclose = () => { 93 | if (error) { 94 | reject(error); 95 | } else { 96 | resolve(response); 97 | } 98 | }; 99 | 100 | socket.onerror = reject; 101 | }); 102 | } 103 | 104 | export default send; 105 | export { STATUS as SOCKET_STATUS }; 106 | -------------------------------------------------------------------------------- /client/src/app/utils.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'lodash'; 3 | import math from 'mathjs'; 4 | 5 | import Vec2 from './viewer/vec2'; 6 | 7 | /** 8 | * Transforms a mathjs matrix to an array that can be used with the 9 | * CanvasRenderingContext2d.setTransform method. 10 | */ 11 | export function mathjsToTransform(matrix) { 12 | /* eslint-disable no-underscore-dangle */ 13 | return [ 14 | ...matrix.subset(math.index([0, 1], 0))._data, 15 | ...matrix.subset(math.index([0, 1], 1))._data, 16 | ...matrix.subset(math.index([0, 1], 2))._data, 17 | ]; 18 | } 19 | 20 | /** 21 | * Transforms an array compatible with the 22 | * CanvasRenderingContext2d.setTransform method to a mathjs matrix. 23 | */ 24 | export function transformToMathjs(matrix) { 25 | return math.matrix([ 26 | [matrix[0], matrix[2], matrix[4]], 27 | [matrix[1], matrix[3], matrix[5]], 28 | [0, 0, 1], 29 | ]); 30 | } 31 | 32 | /** 33 | * Left-multiply Vec2 vector by mathjs transformation matrix 34 | */ 35 | export function mulVec2(matrix, vector) { 36 | return _.flowRight( 37 | /* eslint-disable no-underscore-dangle */ 38 | v => Vec2.Vec2(...v._data), 39 | v => math.subset(v, math.index([0, 1])), 40 | v => math.multiply(matrix, v), 41 | v => math.matrix([v.x, v.y, 1]), 42 | )(vector); 43 | } 44 | 45 | /** 46 | * Transforms canvas vector to its corresponding image coordinates in a given layer 47 | */ 48 | export function toLayerCoordinates(layer, vector) { 49 | return mulVec2(math.inv(layer.tmat), vector); 50 | } 51 | 52 | /** 53 | * Transforms the image coordinates of a point in a given layer to its corresponding canvas vector 54 | * (inverse of {@link toLayerCoordinates}) 55 | */ 56 | export function fromLayerCoordinates(layer, vector) { 57 | return mulVec2(layer.tmat, vector); 58 | } 59 | 60 | /** 61 | * Computes the ''inner integer bounding box'' of a hypercube, given its center and side length. 62 | */ 63 | export function intBounds(center, length) { 64 | return _.map(center, c => _.map([-1, 1], k => k * Math.floor(length + (k * c)))); 65 | } 66 | 67 | /** 68 | * Computes all possible combinations between the elements in two arrays. 69 | */ 70 | export function combinations(arr1, arr2) { 71 | return _.reduce( 72 | _.map(arr1, a1 => _.map(arr2, a2 => [a1, a2])), 73 | (a, x) => a.concat(x), 74 | [], 75 | ); 76 | } 77 | 78 | /** 79 | * Splits an array a into subarrays, each of length n. If the length of a isn't divisible by n, the 80 | * last subarray will have length n % a.length. 81 | */ 82 | export function chunksOf(n, a) { 83 | if (a.length <= n) { 84 | return [a]; 85 | } 86 | const ret = [_.take(a, n)]; 87 | ret.push(...chunksOf(n, _.drop(a, n))); 88 | return ret; 89 | } 90 | 91 | /** 92 | * Sets the current cursor. 93 | */ 94 | export function setCursor(name) { 95 | // FIXME: remove when there's wider support for unprefixed names 96 | const tryAdd = ([prefix, ...rest]) => { 97 | if (prefix === undefined) { 98 | throw new Error(`Unable to set cursor '${name}'`); 99 | } 100 | const prefixName = `${prefix}${name}`; 101 | $('body').css('cursor', prefixName); 102 | if ($('body').css('cursor') !== prefixName) { 103 | tryAdd(rest); 104 | } 105 | }; 106 | tryAdd([ 107 | '', 108 | '-webkit-', 109 | '-moz-', 110 | '-ms-', 111 | '-o-', 112 | ]); 113 | } 114 | -------------------------------------------------------------------------------- /client/src/app/viewer/calibrator.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import math from 'mathjs'; 3 | 4 | import { FilledCircle } from './graphics/circle'; 5 | import { Line } from './graphics/line'; 6 | import { collides } from './graphics/functions'; 7 | import { 8 | CALIBRATOR_LINE_COL_DEF, 9 | CALIBRATOR_LINE_COL_HLT, 10 | CALIBRATOR_LINE_WGHT, 11 | CALIBRATOR_GRID_COL, 12 | CALIBRATOR_GRID_WGHT, 13 | CALIBRATOR_CORNER_COL, 14 | CALIBRATOR_CORNER_WGHT, 15 | } from '../config'; 16 | 17 | 18 | export const SEL = Object.freeze({ 19 | L: 1 << 0, // left 20 | R: 1 << 1, // right 21 | T: 1 << 2, // top 22 | B: 1 << 3, // bottom 23 | }); 24 | 25 | function createLines(n) { 26 | return _.map( 27 | _.range(n), 28 | k => new Line( 29 | 0, 0, 0, 0, 30 | k === 0 || k === n - 1 31 | ? { 32 | lineWidth: CALIBRATOR_LINE_WGHT, 33 | lineColor: CALIBRATOR_LINE_COL_DEF, 34 | } 35 | : { 36 | lineWidth: CALIBRATOR_GRID_WGHT, 37 | lineColor: CALIBRATOR_GRID_COL, 38 | }, 39 | ), 40 | ); 41 | } 42 | 43 | // Calibrator private members 44 | const arrayW = Symbol('Array width'); 45 | const arrayH = Symbol('Array height'); 46 | 47 | const sx0 = Symbol('Left coordinate'); 48 | const sx1 = Symbol('Right coordinate'); 49 | const sy0 = Symbol('Top coordinate'); 50 | const sy1 = Symbol('Bottom coordinate'); 51 | 52 | const ssel = Symbol('Selection flags'); 53 | 54 | const svlines = Symbol('Vertical lines'); 55 | const shlines = Symbol('Horizontal lines'); 56 | const scircles = Symbol('Corner circles'); 57 | 58 | class Calibrator { 59 | constructor() { 60 | this[arrayW] = Number(); 61 | this[arrayH] = Number(); 62 | 63 | this[sx0] = Number(); 64 | this[sy0] = Number(); 65 | this[sx1] = Number(); 66 | this[sy1] = Number(); 67 | 68 | this[ssel] = 0; 69 | 70 | this[svlines] = []; 71 | this[shlines] = []; 72 | 73 | this[scircles] = _.zip( 74 | [ 75 | SEL.L | SEL.T, 76 | SEL.L | SEL.B, 77 | SEL.R | SEL.T, 78 | SEL.R | SEL.B, 79 | ], 80 | _.times(4, () => new FilledCircle( 81 | 0, 0, 82 | CALIBRATOR_CORNER_WGHT, 83 | { fillColor: CALIBRATOR_CORNER_COL }, 84 | )), 85 | ); 86 | } 87 | 88 | updateGraphics() { 89 | /* eslint-disable no-param-reassign */ 90 | /* eslint-disable no-multi-assign */ 91 | _.each( 92 | _.zip(_.range(this.height), this[shlines]), 93 | ([i, x]) => { 94 | x.y0 = x.y1 = ((this.y1 - this.y0) * 95 | (i / (this.height - 1))) + this.y0; 96 | x.x0 = this.x0; 97 | x.x1 = this.x1; 98 | }, 99 | ); 100 | _.each( 101 | _.zip(_.range(this.width), this[svlines]), 102 | ([i, x]) => { 103 | x.x0 = x.x1 = ((this.x1 - this.x0) * 104 | (i / (this.width - 1))) + this.x0; 105 | x.y0 = this.y0; 106 | x.y1 = this.y1; 107 | }, 108 | ); 109 | 110 | _.each( 111 | this[scircles], 112 | ([corner, x]) => { 113 | x.x = corner & SEL.L ? this.x0 : this.x1; 114 | x.y = corner & SEL.T ? this.y0 : this.y1; 115 | }, 116 | ); 117 | } 118 | 119 | setSelection(x, y) { 120 | /* eslint-disable no-param-reassign */ 121 | const collidesxy = _.partial(collides, x, y); 122 | const selLines = [ 123 | [SEL.L, _.head(this[svlines])], 124 | [SEL.R, _.last(this[svlines])], 125 | [SEL.T, _.head(this[shlines])], 126 | [SEL.B, _.last(this[shlines])], 127 | ]; 128 | this[ssel] = _.reduce( 129 | _.filter( 130 | _.concat(selLines, this[scircles]), 131 | ([, z]) => collidesxy(z), 132 | ), 133 | (a, [s]) => a | s, 134 | 0, 135 | ); 136 | _.each( 137 | selLines, 138 | ([s, z]) => { 139 | z.lineColor = this.selection & s 140 | ? CALIBRATOR_LINE_COL_HLT 141 | : CALIBRATOR_LINE_COL_DEF; 142 | }, 143 | ); 144 | } 145 | 146 | setSelectionCoordinates(x, y) { 147 | if (this.selection & SEL.L) { this.x0 = x; } 148 | if (this.selection & SEL.R) { this.x1 = x; } 149 | if (this.selection & SEL.T) { this.y0 = y; } 150 | if (this.selection & SEL.B) { this.y1 = y; } 151 | this.updateGraphics(); 152 | } 153 | 154 | get renderables() { 155 | return _.concat( 156 | this[shlines], 157 | this[svlines], 158 | _.map(this[scircles], _.last), 159 | ); 160 | } 161 | 162 | get selection() { return this[ssel]; } 163 | 164 | get points() { 165 | return [ 166 | [this[sx0], this[sy0]], 167 | [this[sx1], this[sy1]], 168 | ]; 169 | } 170 | 171 | set points([[x0, y0], [x1, y1]]) { 172 | this[sx0] = x0; 173 | this[sy0] = y0; 174 | this[sx1] = x1; 175 | this[sy1] = y1; 176 | this.updateGraphics(); 177 | } 178 | 179 | get x0() { return this[sx0]; } 180 | get x1() { return this[sx1]; } 181 | get y0() { return this[sy0]; } 182 | get y1() { return this[sy1]; } 183 | 184 | set x0(v) { this[sx0] = v; this.updateGraphics(); } 185 | set x1(v) { this[sx1] = v; this.updateGraphics(); } 186 | set y0(v) { this[sy0] = v; this.updateGraphics(); } 187 | set y1(v) { this[sy1] = v; this.updateGraphics(); } 188 | 189 | get width() { return this[arrayW]; } 190 | get height() { return this[arrayH]; } 191 | 192 | set width(v) { 193 | this[arrayW] = v; 194 | this[svlines] = createLines(v); 195 | this.updateGraphics(); 196 | } 197 | set height(v) { 198 | this[arrayH] = v; 199 | this[shlines] = createLines(v); 200 | this.updateGraphics(); 201 | } 202 | } 203 | 204 | export function arr2pxMatrix(calibrator) { 205 | const [[x0, y0], [x1, y1]] = calibrator.points; 206 | return _.flowRight( 207 | /* eslint-disable array-bracket-spacing, no-multi-spaces */ 208 | ([[s0], [s1], [d0], [d1]]) => [ 209 | [s0, 0, d0], 210 | [ 0, s1, d1], 211 | [ 0, 0, 1], 212 | ], 213 | _.partial( 214 | math.multiply, 215 | math.inv([ 216 | [ 1, 0, 1, 0], 217 | [ 0, 1, 0, 1], 218 | [calibrator.width, 0, 1, 0], 219 | [ 0, calibrator.height, 0, 1], 220 | ]), 221 | ), 222 | )( 223 | [[x0], [y0], [x1], [y1]], 224 | ); 225 | } 226 | 227 | export function arr2px(calibrator, pts) { 228 | if (pts.length === 0) { return []; } 229 | return _.flowRight( 230 | as => _.zip(...as), 231 | _.partial(_.take, _, 2), 232 | _.partial(math.multiply, arr2pxMatrix(calibrator)), 233 | )([ 234 | ..._.zip(...pts), 235 | (new Array(pts.length)).fill(1), 236 | ]); 237 | } 238 | 239 | export function px2arr(calibrator, pts) { 240 | if (pts.length === 0) { return []; } 241 | const T = (() => { 242 | try { 243 | return math.inv(arr2pxMatrix(calibrator)); 244 | } catch (e) { 245 | return math.zeros(3, 3); 246 | } 247 | })(); 248 | return _.flowRight( 249 | as => _.zip(...as), 250 | _.partial(_.take, _, 2), 251 | _.partial(math.multiply, T), 252 | )([ 253 | ..._.zip(...pts), 254 | (new Array(pts.length)).fill(1), 255 | ]); 256 | } 257 | 258 | export function arr2assignment(calibrator, pts) { 259 | const [assignX, assignY] = _.map( 260 | [calibrator.width, calibrator.height], 261 | max => _.flowRight( 262 | Math.round, 263 | _.partial(Math.max, 1), 264 | _.partial(Math.min, max), 265 | ), 266 | ); 267 | return _.map(pts, ([x, y]) => [assignX(x), assignY(y)]); 268 | } 269 | 270 | export const px2assignment = (calibrator, ...args) => 271 | _.flowRight( 272 | _.partial(arr2assignment, calibrator), 273 | _.partial(px2arr, calibrator), 274 | )(...args); 275 | 276 | export default Calibrator; 277 | -------------------------------------------------------------------------------- /client/src/app/viewer/camera.js: -------------------------------------------------------------------------------- 1 | /* modified version of https://github.com/robashton/camera */ 2 | 3 | import math from 'mathjs'; 4 | 5 | import Codes from './keycodes'; 6 | import Vec2 from './vec2'; 7 | 8 | import { mulVec2 } from '../utils'; 9 | 10 | const Camera = (function() { 11 | 12 | var self; 13 | var Camera = function(ctx, layerManager, initialPosition, initialScale) { 14 | self = this; 15 | self.context = ctx; 16 | self.layerManager = layerManager; 17 | self.position = initialPosition || Vec2.Vec2(0, 0); 18 | self.scale = initialScale || 0.05; 19 | self.positionOffset = self.calculateOffset(); 20 | self.viewport = { 21 | l: 0, r: 0, 22 | t: 0, b: 0, 23 | width: 0, 24 | height: 0, 25 | scale: Vec2.Vec2(1, 1) 26 | }; 27 | self.navFactor = 60; 28 | self.scaleFactor = 0.8; 29 | self.minScale = 0.03; 30 | self.maxScale = 1.00; 31 | self.positionBoundaries = {"minX": 0, "maxX": 20480, "minY": 0, "maxY": 20480}; 32 | self.updateViewport(); 33 | }; 34 | 35 | Camera.prototype = { 36 | begin: function(context = self.context) { 37 | context.save(); 38 | context.scale(self.scale, self.scale); 39 | context.translate(-self.viewport.l + self.positionOffset.x, -self.viewport.t + self.positionOffset.y); 40 | }, 41 | end: function(context = self.context) { 42 | context.restore(); 43 | }, 44 | getTransform: function() { 45 | return math.matrix([ 46 | [ 47 | self.scale, 48 | 0, 49 | self.scale * (-self.position.x + self.positionOffset.x), 50 | ], 51 | [ 52 | 0, 53 | self.scale, 54 | self.scale * (-self.position.y + self.positionOffset.y), 55 | ], 56 | [0, 0, 1], 57 | ]); 58 | }, 59 | updateViewport: function() { 60 | self.clampValues(); 61 | self.positionOffset = self.calculateOffset(); 62 | self.aspectRatio = self.context.canvas.width / self.context.canvas.height; 63 | self.viewport.l = self.position.x - (self.viewport.width / 2.0); 64 | self.viewport.t = self.position.y - (self.viewport.height / 2.0); 65 | self.viewport.r = self.viewport.l + self.viewport.width; 66 | self.viewport.b = self.viewport.t + self.viewport.height; 67 | }, 68 | zoomTo: function(z) { 69 | self.scale = z; 70 | self.updateViewport(); 71 | }, 72 | moveTo: function(pos) { 73 | self.position = pos; 74 | self.updateViewport(); 75 | }, 76 | navigate: function(dir, zoomCenter) { 77 | var canvasCenter = Vec2.Vec2(self.context.canvas.width / 2, self.context.canvas.height / 2); 78 | var center = zoomCenter || canvasCenter; // the position to which the camera will zoom towards 79 | var movement = Vec2.subtract(center, canvasCenter); // distance between position and canvas center 80 | 81 | var scaleFactor = 1.0; 82 | if(dir === Codes.keyEvent.left) { 83 | movement.x -= self.navFactor; 84 | } 85 | else if(dir === Codes.keyEvent.up) { 86 | movement.y -= self.navFactor; 87 | } 88 | else if(dir === Codes.keyEvent.right) { 89 | movement.x += self.navFactor; 90 | } 91 | else if(dir === Codes.keyEvent.down) { 92 | movement.y += self.navFactor; 93 | } 94 | else if(dir === Codes.keyEvent.zin) { 95 | scaleFactor = 1 / self.scaleFactor; // 1.05 96 | } 97 | else if(dir === Codes.keyEvent.zout) { 98 | scaleFactor = self.scaleFactor; // 0.95 99 | } 100 | 101 | // scaling it down for slight movement 102 | movement = Vec2.scale(movement, 1 - (1 / scaleFactor)); 103 | 104 | if((scaleFactor > 1.0 && self.scale == self.maxScale) || 105 | (scaleFactor < 1.0 && self.scale == self.minScale)) { 106 | // if at min/max boundaries and trying to zoom in/out further, then do nothing 107 | } 108 | else { 109 | self.pan(movement); 110 | self.zoom(scaleFactor); 111 | } 112 | }, 113 | pan: function(movement) { 114 | // takes an object {x, y} and moves the camera with that distance // 115 | movement = self.mouseToCameraScale(movement, 1 / self.scale); 116 | self.position = Vec2.add(self.position, movement); 117 | self.updateViewport(); 118 | }, 119 | zoom: function(scaleFactor) { 120 | self.scale *= scaleFactor; 121 | self.updateViewport(); 122 | }, 123 | calculateOffset: function() { 124 | var canvasMiddle = Vec2.Vec2(self.context.canvas.width / 2, self.context.canvas.height / 2); 125 | var offset = Vec2.scale(canvasMiddle, 1 / self.scale); 126 | return offset; 127 | }, 128 | clampValues: function() { 129 | // keep the scale and position values within reasonable limits 130 | self.position = Vec2.clampX(self.position, self.positionBoundaries.minX, self.positionBoundaries.maxX); 131 | self.position = Vec2.clampY(self.position, self.positionBoundaries.minY, self.positionBoundaries.maxY); 132 | self.scale = Math.max(self.scale, self.minScale); 133 | self.scale = Math.min(self.scale, self.maxScale); 134 | }, 135 | mouseToCameraPosition: function(position, layerName) { 136 | const cam = Vec2.subtract(self.position, self.positionOffset); 137 | const mouse = self.mouseToCameraScale(position, 1 / self.scale); 138 | const canvasPosition = Vec2.add(cam, mouse); 139 | try { 140 | return mulVec2( 141 | math.inv(self.layerManager.getLayer(layerName).tmat), 142 | canvasPosition, 143 | ); 144 | } catch (e) { 145 | return canvasPosition; 146 | } 147 | }, 148 | mouseToCameraScale: function(vector, layerName) { 149 | // this does not take the camera position into account, so it is ideal 150 | // for use with values such as difference/movement values 151 | const canvasScale = Vec2.scale(vector, 1 / self.scale); 152 | try { 153 | const tmat = math.inv(self.layerManager.getLayer(layerName).tmat); 154 | // remove translational offsets 155 | tmat.subset(math.index([0, 1], 2), [0, 0]); 156 | return mulVec2(tmat, canvasScale); 157 | } catch (e) { 158 | return canvasScale; 159 | } 160 | } 161 | }; 162 | 163 | return Camera; 164 | 165 | }()); 166 | 167 | export default Camera; 168 | -------------------------------------------------------------------------------- /client/src/app/viewer/collision-tracker.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { 4 | COLLISION_LINE_DEF, 5 | COLLISION_LINE_HLT, 6 | COLLISION_LINE_WGHT, 7 | } from '../config'; 8 | import { 9 | px2assignment, 10 | arr2px, 11 | } from './calibrator'; 12 | import { Line } from './graphics/line'; 13 | 14 | const salines = Symbol('Assignment lines'); 15 | const sbins = Symbol('Assignment bins'); 16 | 17 | class CollisionTracker { 18 | constructor(calibrator, spots) { 19 | this.calibrator = calibrator; 20 | this.spots = spots; 21 | 22 | this[salines] = []; 23 | this[sbins] = []; 24 | this.update(); 25 | } 26 | update() { 27 | const assigned = []; 28 | const assignedArr = []; 29 | const unassigned = []; 30 | _.each( 31 | this.spots, 32 | (s) => { 33 | const { x, y } = s.position; 34 | const { x: ax, y: ay } = s.assignment; 35 | if (ax && ay) { 36 | assigned.push([x, y]); 37 | assignedArr.push([ax, ay]); 38 | } else { 39 | unassigned.push([x, y]); 40 | } 41 | }, 42 | ); 43 | const unassignedArr = px2assignment(this.calibrator, unassigned); 44 | const orig = [...unassigned, ...assigned]; 45 | const arrs = [...unassignedArr, ...assignedArr]; 46 | const to = arr2px(this.calibrator, arrs); 47 | this[sbins] = _.map( 48 | _.range(this.calibrator.width + 1), 49 | () => new Array(this.calibrator.height + 1).fill(0), 50 | ); 51 | _.each(arrs, ([x, y]) => { this[sbins][x][y] += 1; }); 52 | this[salines] = _.map( 53 | _.zip(orig, to, arrs), 54 | ([as, bs, [x, y]]) => new Line( 55 | ...as, ...bs, 56 | { 57 | lineColor: 58 | this[sbins][x][y] > 1 59 | ? COLLISION_LINE_HLT 60 | : COLLISION_LINE_DEF, 61 | lineWidth: COLLISION_LINE_WGHT, 62 | }, 63 | ), 64 | ); 65 | } 66 | get collisions() { 67 | return _.map( 68 | _.filter( 69 | _.concat(..._.map( 70 | _.zip(this[sbins], _.range(this[sbins].length)), 71 | ([as, x]) => _.map( 72 | _.zip(as, _.range(as.length)), 73 | ([a, y]) => [a, x, y], 74 | ), 75 | )), 76 | ([a]) => a > 1, 77 | ), 78 | _.tail, 79 | ); 80 | } 81 | get renderables() { return this[salines]; } 82 | } 83 | 84 | export default CollisionTracker; 85 | -------------------------------------------------------------------------------- /client/src/app/viewer/event-handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Codes from './keycodes'; 4 | import Vec2 from './vec2'; 5 | 6 | const EventHandler = (function() { 7 | var self; 8 | var EventHandler = function(scopeData, canvas, camera) { 9 | self = this; 10 | self.canvas = canvas; 11 | self.camera = camera; 12 | self.scopeData = scopeData; 13 | 14 | self.mousePos = {}; 15 | self.mouseDown = false; 16 | self.mouseButtonDown = 0; 17 | 18 | self.setUpMouseEvents(self.canvas, self.camera); 19 | self.setUpKeyEvents(self.canvas, self.camera); 20 | }; 21 | 22 | EventHandler.prototype = { 23 | passEventToLogicHandler: function(evt) { 24 | var logicHandler = self.scopeData.logicHandler; 25 | if(logicHandler === null) 26 | return; 27 | if(evt.type == 'mouse') { 28 | logicHandler.processMouseEvent(evt.eventType, evt.data); 29 | } 30 | else if(evt.type == 'key') { 31 | if(evt.keyDirection == 'down') { 32 | logicHandler.processKeydownEvent(evt.keyEvent); 33 | } 34 | else if(evt.keyDirection == 'up') { 35 | logicHandler.processKeyupEvent(evt.keyEvent); 36 | } 37 | } 38 | }, 39 | setUpMouseEvents: function(canvas, camera) { 40 | canvas.onmousedown = function(e) { 41 | self.mousePos = Vec2.Vec2(e.layerX, e.layerY); 42 | self.mouseDown = true; 43 | self.mouseButtonDown = e.button; 44 | var mouseEvent = { 45 | type: 'mouse', 46 | eventType: Codes.mouseEvent.down, 47 | data: { 48 | position: self.mousePos, 49 | button: e.button, 50 | ctrl: e.ctrlKey 51 | } 52 | 53 | } 54 | self.passEventToLogicHandler(mouseEvent); 55 | }; 56 | canvas.onmouseup = function(e) { 57 | self.mousePos = Vec2.Vec2(e.layerX, e.layerY); 58 | self.mouseDown = false; 59 | var mouseEvent = { 60 | type: 'mouse', 61 | eventType: Codes.mouseEvent.up, 62 | data: { 63 | position: self.mousePos, 64 | button: e.button, 65 | ctrl: e.ctrlKey 66 | } 67 | 68 | } 69 | self.passEventToLogicHandler(mouseEvent); 70 | }; 71 | canvas.onmousemove = function(e) { 72 | var distanceMoved = Vec2.Vec2(self.mousePos.x - e.layerX, self.mousePos.y - e.layerY); 73 | self.mousePos = Vec2.Vec2(e.layerX, e.layerY); 74 | 75 | var mouseButton = e.button; 76 | var thisEventType; 77 | if(self.mouseDown) { 78 | mouseButton = self.mouseButtonDown; // required for Firefox, otherwise it attributes all movement to the left button 79 | thisEventType = Codes.mouseEvent.drag; 80 | } 81 | else { 82 | thisEventType = Codes.mouseEvent.move; 83 | } 84 | var mouseEvent = { 85 | type: 'mouse', 86 | eventType: thisEventType, 87 | data: { 88 | position: self.mousePos, 89 | difference: distanceMoved, 90 | button: mouseButton, 91 | ctrl: e.ctrlKey 92 | } 93 | 94 | } 95 | self.passEventToLogicHandler(mouseEvent); 96 | }; 97 | function wheelCallback(e) { 98 | self.mousePos = Vec2.Vec2(e.layerX, e.layerY); 99 | var direction; 100 | if(e.deltaY < 0 || e.detail < 0) { 101 | direction = Codes.keyEvent.zin; 102 | } 103 | else if(e.deltaY > 0 || e.detail > 0) { 104 | direction = Codes.keyEvent.zout; 105 | } 106 | var mouseEvent = { 107 | type: 'mouse', 108 | eventType: Codes.mouseEvent.wheel, 109 | data: { 110 | position: self.mousePos, 111 | direction: direction 112 | } 113 | 114 | } 115 | self.passEventToLogicHandler(mouseEvent); 116 | }; 117 | canvas.addEventListener('DOMMouseScroll', wheelCallback, false); 118 | canvas.onmousewheel = wheelCallback; 119 | }, 120 | setUpKeyEvents: function(canvas, camera) { 121 | document.onkeydown = function(event) { 122 | registerKeyEvent(event, 'down'); 123 | }; 124 | document.onkeyup = function(event) { 125 | registerKeyEvent(event, 'up'); 126 | } 127 | 128 | var registerKeyEvent = function(event, keyDirection) { 129 | event = event || window.event; 130 | var keyName; 131 | for(var key in Codes.keys) { // iterating through the possible keycodes 132 | if(Codes.keys.hasOwnProperty(key)) { // only counts as a key if it's in a direct property 133 | if(Codes.keys[key].includes(event.which)) { // is the event one of the keys? 134 | // then that's the key we want! 135 | keyName = key; 136 | } 137 | } 138 | } 139 | // send it to the logic handler if not undefined 140 | if(keyName) { 141 | var keyEvent = { 142 | type: 'key', 143 | keyDirection: keyDirection, 144 | keyEvent: Codes.keyEvent[keyName] 145 | }; 146 | self.passEventToLogicHandler(keyEvent); 147 | } 148 | }; 149 | } 150 | }; 151 | 152 | return EventHandler; 153 | 154 | }()); 155 | 156 | export default EventHandler; 157 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/circle.js: -------------------------------------------------------------------------------- 1 | import math from 'mathjs'; 2 | 3 | import GraphicsObject from './graphics-object'; 4 | import fillMixin from './fill'; 5 | import strokeMixin from './stroke'; 6 | import { 7 | collidesAdd, 8 | collidesNxt, 9 | renderAdd, 10 | renderNxt, 11 | scaleAdd, 12 | scaleNxt, 13 | } from './functions'; 14 | 15 | const cType = collidesNxt(); 16 | const rType = renderNxt(); 17 | const sType = scaleNxt(); 18 | 19 | // private members of circleMixin 20 | const sx = Symbol('Circle first coordinate'); 21 | const sy = Symbol('Circle second coordinate'); 22 | const sr = Symbol('Circle radius'); 23 | 24 | const sscale = Symbol('Circle scale factor'); 25 | 26 | const circleMixin = s => class extends s { 27 | static ctype() { return cType; } 28 | static rtype() { return rType; } 29 | static stype() { return sType; } 30 | 31 | constructor(x, y, r, kwargs = {}) { 32 | super(kwargs); 33 | 34 | this[sx] = x; 35 | this[sy] = y; 36 | this[sr] = r; 37 | 38 | const { scale } = kwargs; 39 | this[sscale] = scale || 1; 40 | } 41 | 42 | get x() { return this[sx]; } 43 | get y() { return this[sy]; } 44 | get r() { return this[sr]; } 45 | 46 | set x(value) { this[sx] = value; } 47 | set y(value) { this[sy] = value; } 48 | set r(value) { this[sr] = value; } 49 | 50 | get scale() { return this[sscale]; } 51 | }; 52 | 53 | renderAdd( 54 | rType, 55 | (ctx, circle) => { 56 | ctx.beginPath(); 57 | ctx.arc(circle.x, circle.y, circle[sscale] * circle.r, -Math.PI, Math.PI); 58 | ctx.closePath(); 59 | }, 60 | ); 61 | 62 | collidesAdd( 63 | cType, 64 | (x, y, circle) => { 65 | const v = math.subtract([x, y], [circle.x, circle.y]); 66 | return math.dot(v, v) < (circle.scale * circle.r) ** 2; 67 | }, 68 | ); 69 | 70 | scaleAdd( 71 | sType, 72 | /* eslint-disable no-param-reassign */ 73 | (scale, circle) => { circle[sscale] = scale; }, 74 | ); 75 | 76 | const FilledCircle = circleMixin(fillMixin(GraphicsObject)); 77 | const StrokedCircle = circleMixin(strokeMixin(GraphicsObject)); 78 | 79 | export default circleMixin; 80 | export { 81 | FilledCircle, 82 | StrokedCircle, 83 | }; 84 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/fill.js: -------------------------------------------------------------------------------- 1 | import { 2 | renderNxt, 3 | renderAdd, 4 | } from './functions'; 5 | 6 | const frType = renderNxt(); 7 | 8 | const sfcolor = Symbol('Fill color'); 9 | 10 | const fillMixin = s => class extends s { 11 | static rtype() { return frType; } 12 | constructor(kwargs = {}) { 13 | super(kwargs); 14 | const { fillColor } = kwargs; 15 | this.fillColor = fillColor || 'black'; 16 | } 17 | get fillColor() { return this[sfcolor]; } 18 | set fillColor(value) { this[sfcolor] = value; } 19 | }; 20 | 21 | renderAdd( 22 | frType, 23 | (ctx, x) => { 24 | ctx.fillStyle = x.fillColor; 25 | ctx.fill(); 26 | }, 27 | ); 28 | 29 | export default fillMixin; 30 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/functions.js: -------------------------------------------------------------------------------- 1 | // renderer 2 | const jtblRender = []; 3 | export function renderAdd(type, rfnc) { jtblRender[type] = rfnc; } 4 | export function renderNxt() { return jtblRender.length; } 5 | 6 | /** 7 | * Renders graphics object on a given canvas 2d context 8 | */ 9 | export function render(ctx, obj) { 10 | ctx.save(); 11 | let constructor = obj.constructor; 12 | while (constructor.rtype !== undefined) { 13 | jtblRender[constructor.rtype()](ctx, obj); 14 | constructor = Object.getPrototypeOf(constructor); 15 | } 16 | ctx.restore(); 17 | } 18 | 19 | // collision detector 20 | const jtblCollides = []; 21 | export function collidesAdd(type, cfnc) { jtblCollides[type] = cfnc; } 22 | export function collidesNxt() { return jtblCollides.length; } 23 | 24 | /** 25 | * Checks if a position is colliding with a given graphics object 26 | */ 27 | export function collides(x, y, obj) { 28 | let constructor = obj.constructor; 29 | while (constructor.ctype !== undefined) { 30 | if (jtblCollides[constructor.ctype()](x, y, obj)) { 31 | return true; 32 | } 33 | constructor = Object.getPrototypeOf(constructor); 34 | } 35 | return false; 36 | } 37 | 38 | // scaler 39 | const jtblScale = []; 40 | export function scaleAdd(type, sfnc) { jtblScale[type] = sfnc; } 41 | export function scaleNxt() { return jtblScale.length; } 42 | 43 | /** 44 | * Rescales the graphics object and returns it 45 | */ 46 | export function scale(value, obj) { 47 | let constructor = obj.constructor; 48 | while (constructor) { 49 | if (constructor.stype !== undefined) { 50 | jtblScale[constructor.stype()](value, obj); 51 | } 52 | constructor = Object.getPrototypeOf(constructor); 53 | } 54 | return obj; 55 | } 56 | 57 | // utility functions 58 | 59 | /** 60 | * Clears the canvas context 61 | */ 62 | export function clear(ctx) { 63 | ctx.save(); 64 | ctx.resetTransform(); 65 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 66 | ctx.restore(); 67 | } 68 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/graphics-object.js: -------------------------------------------------------------------------------- 1 | export default class GraphicsObject { 2 | constructor() { 3 | if (new.target === GraphicsObject) { 4 | throw new TypeError('Call of new on abstract class GraphicsObject not allowed.'); 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/line.js: -------------------------------------------------------------------------------- 1 | import math from 'mathjs'; 2 | 3 | import GraphicsObject from './graphics-object'; 4 | import strokeMixin from './stroke'; 5 | import { 6 | collidesAdd, 7 | collidesNxt, 8 | renderAdd, 9 | renderNxt, 10 | } from './functions'; 11 | 12 | const cType = collidesNxt(); 13 | const rType = renderNxt(); 14 | 15 | // private members of lineMixin 16 | const sx0 = Symbol('Line coordinate 1 of point 1'); 17 | const sy0 = Symbol('Line coordinate 2 of point 1'); 18 | const sx1 = Symbol('Line coordinate 1 of point 2'); 19 | const sy1 = Symbol('Line coordinate 2 of point 2'); 20 | 21 | const lineMixin = s => class extends strokeMixin(s) { 22 | static ctype() { return cType; } 23 | static rtype() { return rType; } 24 | 25 | constructor(x0, y0, x1, y1, kwargs) { 26 | super(kwargs); 27 | this.x0 = x0; 28 | this.y0 = y0; 29 | this.x1 = x1; 30 | this.y1 = y1; 31 | } 32 | 33 | get x0() { return this[sx0]; } 34 | get x1() { return this[sx1]; } 35 | get y0() { return this[sy0]; } 36 | get y1() { return this[sy1]; } 37 | 38 | set x0(value) { this[sx0] = value; } 39 | set x1(value) { this[sx1] = value; } 40 | set y0(value) { this[sy0] = value; } 41 | set y1(value) { this[sy1] = value; } 42 | }; 43 | 44 | renderAdd( 45 | rType, 46 | (ctx, line) => { 47 | ctx.beginPath(); 48 | ctx.moveTo(line.x0, line.y0); 49 | ctx.lineTo(line.x1, line.y1); 50 | }, 51 | ); 52 | 53 | collidesAdd( 54 | cType, 55 | (x, y, line) => { 56 | /* eslint-disable no-multi-spaces, array-bracket-spacing, yoda */ 57 | const v0 = math.subtract( 58 | [line.x1, line.y1], 59 | [line.x0, line.y0], 60 | ); 61 | const v1 = math.subtract( 62 | [ x, y], 63 | [line.x0, line.y0], 64 | ); 65 | const c = math.dot(v0, v1) / math.dot(v0, v0); 66 | const v2 = math.subtract(v1, math.multiply(c, v0)); 67 | return (0 <= c && c <= 1) && 68 | math.dot(v2, v2) < (line.scale * line.lineWidth) ** 2; 69 | }, 70 | ); 71 | 72 | const Line = lineMixin(GraphicsObject); 73 | 74 | export default lineMixin; 75 | export { Line }; 76 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/opacity.js: -------------------------------------------------------------------------------- 1 | import { 2 | renderNxt, 3 | renderAdd, 4 | } from './functions'; 5 | 6 | const orType = renderNxt(); 7 | 8 | const sopacity = Symbol('Opacity'); 9 | 10 | const opacityMixin = s => class extends s { 11 | static rtype() { return orType; } 12 | constructor(...args) { 13 | super(...args); 14 | this.opacity = 1; 15 | } 16 | get opacity() { return this[sopacity]; } 17 | set opacity(value) { this[sopacity] = value; } 18 | }; 19 | 20 | renderAdd(orType, (ctx, x) => { ctx.globalAlpha = x.opacity; }); 21 | 22 | export default opacityMixin; 23 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/rectangle.js: -------------------------------------------------------------------------------- 1 | import GraphicsObject from './graphics-object'; 2 | import fillMixin from './fill'; 3 | import strokeMixin from './stroke'; 4 | import { 5 | collidesAdd, 6 | collidesNxt, 7 | renderAdd, 8 | renderNxt, 9 | } from './functions'; 10 | 11 | const cType = collidesNxt(); 12 | const rType = renderNxt(); 13 | 14 | // private members of circleMixin 15 | const sx0 = Symbol('Rectangle top-left first coordinate'); 16 | const sy0 = Symbol('Rectangle top-left second coordinate'); 17 | const sx1 = Symbol('Rectangle bottom-right first coordinate'); 18 | const sy1 = Symbol('Rectangle bottom-right second coordinate'); 19 | 20 | const rectangleMixin = s => class extends s { 21 | static ctype() { return cType; } 22 | static rtype() { return rType; } 23 | 24 | constructor(x0, y0, x1, y1, kwargs = {}) { 25 | super(kwargs); 26 | this[sx0] = x0; 27 | this[sy0] = y0; 28 | this[sx1] = x1; 29 | this[sy1] = y1; 30 | } 31 | 32 | get x0() { return this[sx0]; } 33 | get y0() { return this[sy0]; } 34 | get x1() { return this[sx1]; } 35 | get y1() { return this[sy1]; } 36 | 37 | set x0(value) { this[sx0] = value; } 38 | set y0(value) { this[sy0] = value; } 39 | set x1(value) { this[sx1] = value; } 40 | set y1(value) { this[sy1] = value; } 41 | 42 | get topLeft() { 43 | return [Math.min(this.x0, this.x1), Math.min(this.y0, this.y1)]; 44 | } 45 | get bottomRight() { 46 | return [Math.max(this.x0, this.x1), Math.max(this.y0, this.y1)]; 47 | } 48 | 49 | set topLeft([x, y]) { 50 | if (this.x0 < this.x1) this.x0 = x; else this.x1 = x; 51 | if (this.y0 < this.y1) this.y0 = y; else this.y1 = y; 52 | } 53 | set bottomRight([x, y]) { 54 | if (this.x0 > this.x1) this.x0 = x; else this.x1 = x; 55 | if (this.y0 > this.y1) this.y0 = y; else this.y1 = y; 56 | } 57 | }; 58 | 59 | renderAdd( 60 | rType, 61 | (ctx, r) => { 62 | const [x0, y0] = r.topLeft; 63 | const [x1, y1] = r.bottomRight; 64 | ctx.beginPath(); 65 | ctx.rect(x0, y0, x1 - x0, y1 - y0); 66 | }, 67 | ); 68 | 69 | collidesAdd( 70 | cType, 71 | (x, y, r) => { 72 | const [x0, y0] = r.topLeft; 73 | const [x1, y1] = r.bottomRight; 74 | return x0 <= x && x <= x1 && y0 <= y && y <= y1; 75 | }, 76 | ); 77 | 78 | const FilledRectangle = rectangleMixin(fillMixin(GraphicsObject)); 79 | const StrokedRectangle = rectangleMixin(strokeMixin(GraphicsObject)); 80 | 81 | export default rectangleMixin; 82 | export { 83 | FilledRectangle, 84 | StrokedRectangle, 85 | }; 86 | -------------------------------------------------------------------------------- /client/src/app/viewer/graphics/stroke.js: -------------------------------------------------------------------------------- 1 | import { 2 | renderNxt, 3 | renderAdd, 4 | scaleAdd, 5 | scaleNxt, 6 | } from './functions'; 7 | 8 | const rType = renderNxt(); 9 | const sType = scaleNxt(); 10 | 11 | const sscolor = Symbol('Line color'); 12 | const sswidth = Symbol('Line width'); 13 | const ssdash = Symbol('Line dash'); 14 | 15 | const sscale = Symbol('Line scale'); 16 | 17 | const strokeMixin = s => class extends s { 18 | static rtype() { return rType; } 19 | static stype() { return sType; } 20 | 21 | constructor(kwargs = {}) { 22 | super(kwargs); 23 | 24 | const { lineDash, lineColor, lineWidth } = kwargs; 25 | 26 | this.lineDash = lineDash || []; 27 | this.lineColor = lineColor || 'black'; 28 | this.lineWidth = lineWidth || 1; 29 | 30 | const { scale } = kwargs; 31 | this[sscale] = scale || 1; 32 | } 33 | 34 | get lineColor() { return this[sscolor]; } 35 | get lineWidth() { return this[sswidth]; } 36 | get lineDash() { return this[ssdash]; } 37 | 38 | set lineColor(value) { this[sscolor] = value; } 39 | set lineWidth(value) { this[sswidth] = value; } 40 | set lineDash(value) { this[ssdash] = value; } 41 | 42 | get scale() { return this[sscale]; } 43 | }; 44 | 45 | renderAdd( 46 | rType, 47 | (ctx, x) => { 48 | ctx.strokeStyle = x.lineColor; 49 | ctx.lineWidth = x.scale * x.lineWidth; 50 | ctx.setLineDash(x.lineDash.map(a => a * x.scale)); 51 | ctx.stroke(); 52 | }, 53 | ); 54 | 55 | scaleAdd( 56 | sType, 57 | /* eslint-disable no-param-reassign */ 58 | (scale, line) => { line[sscale] = scale; }, 59 | ); 60 | 61 | export default strokeMixin; 62 | -------------------------------------------------------------------------------- /client/src/app/viewer/keycodes.js: -------------------------------------------------------------------------------- 1 | const Codes = (function() { 2 | 'use strict'; 3 | 4 | return Object.freeze({ 5 | /* https://css-tricks.com/snippets/javascript/javascript-keycodes/#article-header-id-1 */ 6 | keys: Object.freeze({ 7 | left: [ 37, 65], // left, a 8 | up: [ 38, 87], // up, w 9 | right: [ 39, 68], // right, d 10 | down: [ 40, 83], // down, s 11 | zin: [107, 69], // +, e 12 | zout: [109, 81], // -, q 13 | shift: [ 20, 16], // shift 14 | ctrl: [ 17], // ctrl 15 | esc: [ 27], // escape 16 | del: [ 46, 8], // delete, backspace 17 | undo: [ 85, 90], // u, z 18 | redo: [ 82, 84] // r, y 19 | }), 20 | 21 | keyEvent: Object.freeze({ 22 | left: 1, 23 | up: 2, 24 | right: 3, 25 | down: 4, 26 | zin: 5, 27 | zout: 6, 28 | shift: 7, 29 | ctrl: 8, 30 | esc: 9, 31 | undo: 10, 32 | redo: 11, 33 | }), 34 | 35 | mouseEvent: Object.freeze({ 36 | down: 1, 37 | up: 2, 38 | move: 3, 39 | drag: 4, 40 | wheel: 5 41 | }), 42 | 43 | mouseButton: Object.freeze({ 44 | left: 0, 45 | right: 2 46 | }) 47 | }); 48 | 49 | }()); 50 | 51 | export default Codes; 52 | -------------------------------------------------------------------------------- /client/src/app/viewer/layer-manager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * layer-manager.js 3 | * ---------------- 4 | */ 5 | 6 | import math from 'mathjs'; 7 | import $ from 'jquery'; 8 | import _ from 'lodash'; 9 | 10 | const DEF_MODIFIERS = { 11 | visible: true, 12 | }; 13 | const DEF_TEMPLATE = ''; 14 | 15 | // Symbols for private members 16 | const cb = Symbol('Callback'); 17 | const dm = Symbol('Default modifiers'); 18 | const layers = Symbol('Layers'); 19 | const mod = Symbol('Modifiers'); 20 | const adj = Symbol('Adjustments'); 21 | 22 | // (Re-)appends the layers in the order given by self.layerOrder. 23 | function refreshLayerOrder(layerManager) { 24 | layerManager.layerOrder.forEach( 25 | cur => $(layerManager.container).append(layerManager[layers][cur].canvas)); 26 | } 27 | 28 | /** 29 | * Data structure for the canvas element and the modifiers associated with a layer. 30 | */ 31 | class Layer { 32 | /** 33 | * Constructs a new layer object. 34 | * 35 | * @param {HTMLElement} canvas - The canvas element 36 | * @param {object} defaultModifiers - Default modifiers 37 | */ 38 | constructor(canvas, defaultModifiers, callback) { 39 | this[dm] = defaultModifiers; 40 | this[cb] = callback; 41 | this[mod] = Object.assign({}, defaultModifiers); 42 | this[adj] = []; 43 | 44 | /** 45 | * The canvas element of the layer. 46 | * @type {HTMLElement} 47 | */ 48 | this.canvas = canvas; 49 | 50 | /** 51 | * The transformation matrix of the layer. 52 | * @type {mathjs#matrix} 53 | */ 54 | this.tmat = math.eye(3); 55 | } 56 | 57 | /** 58 | * Getter for layer modifiers. 59 | * 60 | * @param {string} name - The name of the modifier. 61 | * @returns {*} The value of the modifier. 62 | */ 63 | get(name) { 64 | if (!(name in this[dm])) { 65 | throw new Error(`Modifier ${name} does not exist.`); 66 | } 67 | return this[mod][name]; 68 | } 69 | 70 | /** 71 | * Getter for layer modifiers. 72 | * 73 | * @returns {Object} The modifiers on the layer. 74 | */ 75 | getAll() { 76 | return Object.assign({}, this[mod]); 77 | } 78 | 79 | /** 80 | * Setter for layer modifiers. 81 | * 82 | * @param {string} name - The name of the modifier. 83 | * @param {*} value - The value to set the modifier to. 84 | * @param {...*} args - The arguments to pass to the callback function. 85 | * @returns {Layer} The layer object that the function was called on. 86 | */ 87 | set(name, value, ...args) { 88 | if (!(name in this[dm])) { 89 | throw new Error(`Modifier ${name} does not exist.`); 90 | } 91 | this[mod][name] = value; 92 | this[cb](...args); 93 | return this; 94 | } 95 | 96 | /** 97 | * Getter for layer adjustments. 98 | * 99 | * @returns {Array} Array of (Name, Value) tuples representing the current 100 | * adjustments on the layer. 101 | */ 102 | get adjustments() { 103 | return _.clone(this[adj]); 104 | } 105 | 106 | /** 107 | * Setter for layer adjustments. 108 | * 109 | * @param {Array} - Array of (Name, Value) tuples representing the 110 | * adjustments that should be applied upon rendering. 111 | * @returns {Layer} The layer object that the function was called on. 112 | */ 113 | set adjustments(adjustments) { 114 | this[adj] = _.clone(adjustments); 115 | return this; 116 | } 117 | 118 | /** 119 | * Getter for the layer transformation matrix. 120 | * 121 | * @returns {mathjs#matrix} The transformation matrix. 122 | */ 123 | getTransform() { 124 | return this.tmat; 125 | } 126 | 127 | /** 128 | * Sets the layer's transform to the given transformation matrix. 129 | * 130 | * @param {mathjs#matrix} transMatrix - The transformation matrix. 131 | * @returns {Layer} The layer that the function was called on. 132 | */ 133 | setTransform(transMatrix) { 134 | this.tmat = transMatrix; 135 | return this; 136 | } 137 | 138 | /** 139 | * Translates the layer. 140 | * 141 | * @param {mathjs#matrix} translationVector - The translation vector. 142 | * @param {...*} args - The arguments to pass to the callback function. 143 | * @returns {Layer} The layer that the function was called on. 144 | */ 145 | translate(translationVector, ...args) { 146 | const [x, y] = [ 147 | math.subset(translationVector, math.index(0, 0)), 148 | math.subset(translationVector, math.index(1, 0)), 149 | ]; 150 | const translationMatrix = math.matrix([ 151 | [1, 0, -x], 152 | [0, 1, -y], 153 | [0, 0, 1], 154 | ]); 155 | this.tmat = math.multiply(translationMatrix, this.tmat); 156 | this[cb](...args); 157 | return this; 158 | } 159 | 160 | /** 161 | * Rotates the layer around a given rotation point. 162 | * 163 | * @param {number} diff - The rotation angle (in radians) 164 | * @param {mathjs#matrix} rotationPoint - The rotation point 165 | * @param {...*} args - The arguments to pass to the callback function. 166 | * @returns {Layer} The layer that the function was called on. 167 | */ 168 | rotate(diff, rotationPoint, ...args) { 169 | const [x, y] = [ 170 | math.subset(rotationPoint, math.index(0, 0)), 171 | math.subset(rotationPoint, math.index(1, 0)), 172 | ]; 173 | const rotationMatrix = math.matrix([ 174 | [ 175 | Math.cos(diff), 176 | Math.sin(diff), 177 | x - ((x * Math.cos(diff)) + (y * Math.sin(diff))), 178 | ], 179 | [ 180 | -Math.sin(diff), 181 | Math.cos(diff), 182 | y - ((y * Math.cos(diff)) - (x * Math.sin(diff))), 183 | ], 184 | [0, 0, 1], 185 | ]); 186 | this.tmat = math.multiply(rotationMatrix, this.tmat); 187 | this[cb](...args); 188 | return this; 189 | } 190 | } 191 | 192 | /** 193 | * Utility class for managing canvas layers. 194 | */ 195 | class LayerManager { 196 | /** 197 | * Constructs a new LayerManager object. 198 | * 199 | * @param {HTMLElement} container - The {@link LayerManager#container}. 200 | * @param {function} callback - The {@link LayerManager#callback} function. 201 | */ 202 | constructor(container, callback) { 203 | // Private members 204 | this[cb] = (...args) => this.callback(...args); 205 | this[dm] = Object.assign({}, DEF_MODIFIERS); 206 | this[layers] = {}; 207 | 208 | /** 209 | * Function to call whenever a layer gets modified. 210 | * @type {function} 211 | */ 212 | this.callback = callback || (() => null); 213 | 214 | /** 215 | * The container of the layers. Must be a DOM element to which elements can be appended 216 | * (e.g., a div). 217 | * @type {HTMLElement} 218 | */ 219 | this.container = container; 220 | 221 | // Add refresh hook when the layer order gets modified 222 | this.layerOrder = new Proxy([], { 223 | set: (target, property, value) => { 224 | /* eslint-disable no-param-reassign */ 225 | target[property] = value; 226 | refreshLayerOrder(this); 227 | return target; 228 | }, 229 | }); 230 | } 231 | 232 | /** 233 | * Adds a new layer and appends it to the DOM container. 234 | * 235 | * @param {string} name - The name for the layer. Must be unique. 236 | * @param {string} template - The HTML template for the layer. For example, '. 238 | * @returns {Layer} the new layer object. 239 | */ 240 | addLayer(name, template) { 241 | if (name in this[layers]) { 242 | throw new Error(`Layer ${name} already exists!`); 243 | } 244 | const layer = new Layer( 245 | $((template || DEF_TEMPLATE).replace('{name}', name))[0], 246 | this[dm], 247 | this[cb]); 248 | this[layers][name] = layer; 249 | this.layerOrder.push(name); 250 | return layer; 251 | } 252 | 253 | /** 254 | * Deletes a layer and removes it from the DOM conainter. 255 | * 256 | * @param {string} name - The name of the layer. 257 | * @returns {LayerManager} The object that the function was called on. 258 | */ 259 | deleteLayer(name) { 260 | if (!(name in this[layers])) { 261 | throw new Error(`Layer ${name} does not exist!`); 262 | } 263 | this.container.removeChild(this[layers][name].canvas); 264 | this.layerOrder.splice(this.layerOrder.indexOf(name), 1); 265 | delete this[layers][name]; 266 | return this; 267 | } 268 | 269 | /** 270 | * Getter for a single layer 271 | * 272 | * @param {string} name - The name of the layer. 273 | * @returns {Layer} The layer. 274 | */ 275 | getLayer(name) { 276 | if (!(name in this[layers])) { 277 | throw new Error(`Layer ${name} does not exist.`); 278 | } 279 | return this[layers][name]; 280 | } 281 | 282 | /** 283 | * Getter for all layers 284 | * 285 | * @returns {Map} The layers. 286 | */ 287 | getLayers() { 288 | return Object.assign({}, this[layers]); 289 | } 290 | 291 | /** 292 | * Adds a new modifier/property. 293 | * 294 | * @param {string} name - The name of the property. Must be unique. 295 | * @param {*} value - The default value of the property. 296 | * @returns {LayerManager} The object that the function was called on. 297 | */ 298 | addModifier(name, value) { 299 | if (name in this[dm]) { 300 | throw new Error(`Modifier ${name} is already defined.`); 301 | } 302 | this[dm][name] = value; 303 | Object.values(this[layers]).forEach((layer) => { layer[mod] = value; }); 304 | return this; 305 | } 306 | 307 | /** 308 | * Deletes a modifier/property. 309 | * 310 | * @param {string} name - The name of the property. 311 | * @returns {LayerManager} The object that the function was called on. 312 | */ 313 | deleteModifier(name) { 314 | if (!(name in this[dm])) { 315 | throw new Error(`Modifier ${name} does not exist.`); 316 | } 317 | delete this[dm][name]; 318 | Object.values(this[layers]).forEach(layer => delete layer[mod]); 319 | return this; 320 | } 321 | } 322 | 323 | export default LayerManager; 324 | -------------------------------------------------------------------------------- /client/src/app/viewer/logic-handler.adjustment.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { 4 | SELECTION_RECT_COL, 5 | SELECTION_RECT_DASH, 6 | SELECTION_RECT_WGHT, 7 | } from '../config'; 8 | import LogicHandler from '../logic-handler'; 9 | import { UndoAction } from '../viewer/undo'; 10 | import { setCursor } from '../utils'; 11 | import { SEL, px2assignment } from './calibrator'; 12 | import { collides } from './graphics/functions'; 13 | import { StrokedRectangle } from './graphics/rectangle'; 14 | import Codes from './keycodes'; 15 | 16 | 17 | export const STATES = Object.freeze({ 18 | PANNING: 1 << 0, 19 | CALIBRATING: 1 << 1, 20 | HOVERING: 1 << 2, 21 | MOVING: 1 << 3, 22 | SELECTING: 1 << 4, 23 | ADDING: 1 << 5, 24 | EDITING: 1 << 6, 25 | ASSIGNING: 1 << 7, 26 | }); 27 | 28 | 29 | class AdjustmentLH extends LogicHandler { 30 | constructor( 31 | camera, 32 | calibrator, 33 | spotManager, 34 | collisionTracker, 35 | refreshCanvas, 36 | undoStack, 37 | ) { 38 | super(); 39 | this.camera = camera; 40 | this.calibrator = calibrator; 41 | this.spotManager = spotManager; 42 | this.collisionTracker = collisionTracker; 43 | this.refreshCanvas = refreshCanvas; 44 | this.undoStack = undoStack; 45 | 46 | this.state = STATES.DEFAULT; 47 | this.hovering = undefined; 48 | this.editing = undefined; 49 | this.selectionRectangle = undefined; 50 | this.modifiedSelection = new Set(); 51 | 52 | this.recordKeyStates(); 53 | this.recordMousePosition(); 54 | } 55 | 56 | undo(action) { 57 | switch (action.action) { 58 | case 'spotAdjustment': 59 | this.spotManager.spots = action.state; 60 | if (this.state & STATES.ADDING) { 61 | const { x, y } = this.camera.mouseToCameraPosition( 62 | this.mousePosition); 63 | this.spotManager.spotsMutable.push( 64 | this.spotManager.createSpot(x, y)); 65 | } 66 | break; 67 | case 'frameAdjustment': 68 | this.calibrator.points = action.state; 69 | break; 70 | default: 71 | throw new Error(`Unknown undo action ${action.action}`); 72 | } 73 | this.collisionTracker.update(); 74 | this.refreshCanvas(); 75 | } 76 | 77 | get renderables() { 78 | return _.filter( 79 | [ 80 | this.selectionRectangle, 81 | ], 82 | x => x !== undefined, 83 | ); 84 | } 85 | 86 | refreshCursor() { 87 | if (this.state & STATES.PANNING) { 88 | return setCursor( 89 | this.state & STATES.MOVING 90 | ? 'grabbing' 91 | : 'grab' 92 | , 93 | ); 94 | } 95 | if (this.state & STATES.ADDING) { 96 | return setCursor('crosshair'); 97 | } 98 | if (this.state & STATES.EDITING) { 99 | if (this.state & STATES.ASSIGNING) { 100 | return setCursor('crosshair'); 101 | } 102 | return setCursor( 103 | this.state & STATES.HOVERING 104 | ? 'cell' 105 | : 'default' 106 | , 107 | ); 108 | } 109 | if (this.state & STATES.CALIBRATING) { 110 | switch (this.calibrator.selection) { 111 | case SEL.L: 112 | case SEL.R: 113 | return setCursor('ew-resize'); 114 | case SEL.T: 115 | case SEL.B: 116 | return setCursor('ns-resize'); 117 | case SEL.L | SEL.T: 118 | case SEL.R | SEL.B: 119 | return setCursor('nwse-resize'); 120 | case SEL.L | SEL.B: 121 | case SEL.R | SEL.T: 122 | return setCursor('nesw-resize'); 123 | default: 124 | return setCursor('grab'); 125 | } 126 | } 127 | if (this.state & STATES.SELECTING) { 128 | return setCursor('crosshair'); 129 | } 130 | if (this.state & STATES.MOVING) { 131 | return setCursor('grabbing'); 132 | } 133 | if (this.state & STATES.HOVERING) { 134 | return setCursor('move'); 135 | } 136 | return setCursor('crosshair'); 137 | } 138 | 139 | processKeydownEvent(keyEvent) { 140 | switch (keyEvent) { 141 | case Codes.keyEvent.ctrl: 142 | this.state |= STATES.PANNING; 143 | break; 144 | default: 145 | // ignore 146 | } 147 | this.refreshCursor(); 148 | } 149 | 150 | processKeyupEvent(keyEvent) { 151 | switch (keyEvent) { 152 | case Codes.keyEvent.undo: 153 | if (this.undoStack.lastTab() === 'state_adjustment') { 154 | this.undo(this.undoStack.pop()); 155 | } 156 | break; 157 | case Codes.keyEvent.ctrl: 158 | this.state = this.state & (~STATES.PANNING); 159 | break; 160 | default: 161 | // ignore 162 | } 163 | this.refreshCursor(); 164 | } 165 | 166 | processMouseEvent(mouseEvent, eventData) { 167 | const { x, y } = this.camera.mouseToCameraPosition(eventData.position); 168 | const hovering = _.find( 169 | this.spotManager.spotsMutable, 170 | s => collides(x, y, s), 171 | ); 172 | this.state ^= (this.state & STATES.HOVERING) ^ 173 | (hovering ? STATES.HOVERING : 0); 174 | 175 | switch (mouseEvent) { 176 | case Codes.mouseEvent.drag: 177 | if (this.state & STATES.PANNING) { 178 | this.camera.pan(eventData.difference); 179 | } 180 | switch (this.state & (~STATES.HOVERING)) { 181 | case STATES.CALIBRATING: 182 | this.calibrator.setSelectionCoordinates(x, y); 183 | this.collisionTracker.update(); 184 | break; 185 | case STATES.SELECTING: 186 | this.selectionRectangle.x1 = x; 187 | this.selectionRectangle.y1 = y; 188 | _.each( 189 | this.spotManager.spotsMutable, 190 | (s) => { 191 | /* eslint-disable no-param-reassign */ 192 | const v = eventData.button === Codes.mouseButton.left; 193 | if (collides(s.x, s.y, this.selectionRectangle)) { 194 | if (s.selected !== v) { 195 | s.selected = v; 196 | this.modifiedSelection.add(s); 197 | } 198 | } else if (this.modifiedSelection.has(s)) { 199 | s.selected = !v; 200 | this.modifiedSelection.delete(s); 201 | } 202 | }, 203 | ); 204 | break; 205 | case STATES.MOVING: { 206 | const { x: dx, y: dy } = 207 | this.camera.mouseToCameraScale(eventData.difference); 208 | if (this.editing.selected) { 209 | this.spotManager.selected.forEach((s) => { 210 | /* eslint-disable no-param-reassign */ 211 | s.x -= dx; 212 | s.y -= dy; 213 | }); 214 | } else { 215 | this.editing.x -= dx; 216 | this.editing.y -= dy; 217 | } 218 | this.collisionTracker.update(); 219 | } break; 220 | case STATES.EDITING | STATES.ASSIGNING: { 221 | const [[ax, ay]] = px2assignment(this.calibrator, [[x, y]]); 222 | this.editing.assignment = { x: ax, y: ay }; 223 | this.collisionTracker.update(); 224 | } break; 225 | default: 226 | // ignore 227 | } 228 | this.refreshCanvas(); 229 | break; 230 | 231 | case Codes.mouseEvent.wheel: 232 | this.camera.navigate(eventData.direction, eventData.position); 233 | this.refreshCanvas(); 234 | // fall through 235 | 236 | case Codes.mouseEvent.move: { 237 | if (this.state & STATES.ADDING) { 238 | _.last(this.spotManager.spotsMutable).position = { x, y }; 239 | this.collisionTracker.update(); 240 | this.refreshCanvas(); 241 | break; 242 | } 243 | if (this.state & STATES.EDITING) { 244 | break; 245 | } 246 | const oldSelection = this.calibrator.selection; 247 | this.calibrator.setSelection(x, y); 248 | if (this.calibrator.selection !== oldSelection) { 249 | this.refreshCanvas(); 250 | } 251 | if (this.calibrator.selection !== 0 && 252 | !(this.state & STATES.CALIBRATING)) { 253 | this.state |= STATES.CALIBRATING; 254 | } else if (this.calibrator.selection === 0 && 255 | (this.state & STATES.CALIBRATING)) { 256 | this.state &= ~STATES.CALIBRATING; 257 | } 258 | } break; 259 | 260 | case Codes.mouseEvent.down: 261 | if (this.state & STATES.PANNING) { 262 | this.state |= STATES.MOVING; 263 | break; 264 | } 265 | if (this.state & STATES.ADDING) { 266 | this.undoStack.push(new UndoAction( 267 | 'state_adjustment', 268 | 'spotAdjustment', 269 | _.initial(this.spotManager.spots), 270 | )); 271 | this.spotManager.spotsMutable.push( 272 | this.spotManager.createSpot(x, y)); 273 | this.collisionTracker.update(); 274 | break; 275 | } 276 | if (this.state & STATES.EDITING) { 277 | this.editing = hovering; 278 | if (this.editing) { 279 | this.undoStack.push(new UndoAction( 280 | 'state_adjustment', 281 | 'spotAdjustment', 282 | this.spotManager.spots, 283 | )); 284 | this.state |= STATES.ASSIGNING; 285 | } 286 | break; 287 | } 288 | if (this.state & STATES.CALIBRATING) { 289 | this.undoStack.setTemp(new UndoAction( 290 | 'state_adjustment', 291 | 'frameAdjustment', 292 | this.calibrator.points, 293 | )); 294 | break; 295 | } 296 | this.editing = hovering; 297 | this.undoStack.setTemp(new UndoAction( 298 | 'state_adjustment', 299 | 'spotAdjustment', 300 | this.spotManager.spots, 301 | )); 302 | if (this.state & STATES.HOVERING) { 303 | this.state |= STATES.MOVING; 304 | } else { 305 | this.modifiedSelection.clear(); 306 | this.selectionRectangle = new StrokedRectangle( 307 | x, y, x, y, 308 | { 309 | lineColor: SELECTION_RECT_COL, 310 | lineDash: SELECTION_RECT_DASH, 311 | lineWidth: SELECTION_RECT_WGHT, 312 | }, 313 | ); 314 | this.state |= STATES.SELECTING; 315 | } 316 | this.refreshCanvas(); 317 | break; 318 | 319 | case Codes.mouseEvent.up: 320 | if (this.undoStack.temp) { 321 | this.undoStack.pushTemp(); 322 | } 323 | this.selectionRectangle = undefined; 324 | this.refreshCanvas(); 325 | this.state &= ~( 326 | STATES.SELECTING 327 | | STATES.MOVING 328 | | STATES.ASSIGNING 329 | ); 330 | break; 331 | 332 | default: 333 | // ignore 334 | } 335 | 336 | this.refreshCursor(); 337 | } 338 | } 339 | 340 | export default AdjustmentLH; 341 | -------------------------------------------------------------------------------- /client/src/app/viewer/rendering-client.js: -------------------------------------------------------------------------------- 1 | /** @module rendering-client */ 2 | 3 | import _ from 'lodash'; 4 | 5 | import { Messages, Responses } from 'worker/return-codes'; 6 | 7 | import { MAX_CACHE_SIZE, WORKER_PATH } from '../config'; 8 | 9 | /** 10 | * Return code enum for {@link module:rendering-client~RenderingClient}. Contains the following 11 | * keys: 12 | * 13 | * - OOB (Requested tile is out of bounds; callback will not be called) 14 | * - SFC (Tile is being served from cache; callback has already been called) 15 | * - SFW (Tile is being served from worker; callback will be called when the worker returns) 16 | * 17 | * @type {object} 18 | */ 19 | const ReturnCodes = Object.freeze({ 20 | OOB: 'Out of bounds', 21 | SFC: 'Serving from cache', 22 | SFW: 'Serving from worker', 23 | }); 24 | 25 | // private members 26 | const cache = Symbol('Tile cache'); 27 | const cmod = Symbol('Current filters'); 28 | const data = Symbol('Tile data'); 29 | const fltr = Symbol('Filters'); 30 | const mthrd = Symbol('Maximum number of threads'); 31 | const queue = Symbol('Rendering queue'); 32 | const tn = Symbol('Number of tiles'); 33 | const wrk = Symbol('Available workers'); 34 | 35 | function serializeId(x, y, z) { 36 | return [x, y, z].join('#'); 37 | } 38 | 39 | function unserializeId(id) { 40 | return _.map(id.split('#'), x => parseInt(x, 10)); 41 | } 42 | 43 | function createImageData(uri) { 44 | return new Promise((resolve) => { 45 | const image = new Image(); 46 | image.src = uri; 47 | image.onload = () => { 48 | const canvas = document.createElement('canvas'); 49 | canvas.width = image.width; 50 | canvas.height = image.height; 51 | 52 | const ctx = canvas.getContext('2d'); 53 | ctx.drawImage(image, 0, 0); 54 | 55 | resolve(ctx.getImageData(0, 0, canvas.width, canvas.height)); 56 | }; 57 | }); 58 | } 59 | 60 | /* eslint-disable no-param-reassign */ 61 | // (must set the onmessage attribute on the assigned worker.) 62 | function startJob(id, jpg, worker) { 63 | /* eslint-disable consistent-return */ 64 | // (this function will always return undefined but we need the return statement in order for the 65 | // VM to do tail call optimization.) 66 | worker.onmessage = (e) => { 67 | const [msg, [tileData]] = e.data; 68 | const [x, y, z] = unserializeId(id); 69 | if (msg === Responses.SUCCESS) { 70 | const tile = document.createElement('canvas'); 71 | tile.width = tileData.width; 72 | tile.height = tileData.height; 73 | 74 | const ctx = tile.getContext('2d'); 75 | ctx.putImageData(tileData, 0, 0); 76 | 77 | this[cache].set(id, tile); 78 | this.callback(tile, x, y, z); 79 | 80 | // remove tile objects in insertion order until size is below MAX_CACHE_SIZE. 81 | // (assume image data is stored as uint8 in four channels.) 82 | const tileSize = tile.height * tile.width * 4; 83 | const removeN = Math.max( 84 | 0, this[cache].size - Math.floor(MAX_CACHE_SIZE / tileSize)); 85 | Array.from(this[cache].keys()).slice(0, removeN) 86 | .forEach(key => this[cache].delete(key)); 87 | } 88 | 89 | // replace current job with a new one if there is one in queue 90 | while (this[queue].length > 0) { 91 | const [curId, curJpg] = this[queue].pop(); 92 | if (this[cache].has(curId)) { 93 | return startJob.apply(this, [curId, curJpg, worker]); 94 | } 95 | // else: job has already been removed from cache, so we skip it 96 | } 97 | 98 | // no more jobs in queue, so we retire our worker to the worker pool 99 | this[wrk].push(worker); 100 | }; 101 | 102 | createImageData(jpg).then((imagedata) => { 103 | worker.postMessage( 104 | [ 105 | Messages.AFLTR, 106 | [imagedata, this[cmod]], 107 | ], 108 | [imagedata.data.buffer], 109 | ); 110 | }); 111 | } 112 | 113 | /** 114 | * Tile server. Tiles are rendered using {@link module:rendering-worker}. 115 | */ 116 | class RenderingClient { 117 | /** 118 | * Constructs the rendering client. 119 | * 120 | * @param {number} maxThreads - The maximum number of concurrent workers. 121 | * @param {function} callback - The function to call upon completing a tile request. The 122 | * function will be called with the arguments (tile, x, y, z), where tile is the requested tile 123 | * and (x, y, z) is its coordinates. 124 | */ 125 | constructor(maxThreads, callback) { 126 | this[mthrd] = maxThreads; 127 | 128 | this[cache] = new Map(); 129 | this[fltr] = {}; 130 | this[queue] = []; 131 | this[wrk] = []; 132 | 133 | const worker = new Worker(WORKER_PATH); 134 | worker.onmessage = (e) => { 135 | worker.terminate(); 136 | const [msg, [filters]] = e.data; 137 | if (msg === Responses.SUCCESS) { 138 | _.each(filters, (f) => { this[fltr][f] = true; }); 139 | } else { 140 | throw new Error('Failed to get filters from worker.'); 141 | } 142 | }; 143 | worker.postMessage([Messages.GFLTR, null]); 144 | 145 | this.callback = callback; 146 | } 147 | 148 | /** 149 | * Loads the tile data and initializes the rendering workers. 150 | * 151 | * @param {object} tileData - The tiles. Must support the []-operator so that the tile at 152 | * position (x, y, z) can be retrieved using data[z][x][y]. 153 | * @param {Array} histogram - The histogram of the image. The slices 0-255, 256-511, and 512-767 154 | * should contain the frequencies of the R, G, and B pixel intensities, respectively. For 155 | * example, the number of pixels having intensity 100 in the green channel should be equal to 156 | * histogram[356]. 157 | * @returns {Promise} A promise that resolves to the current instance if the initialization 158 | * succeeds. 159 | */ 160 | loadTileData(tileData, histogram) { 161 | this[data] = tileData; 162 | this[tn] = Object.entries(this[data]).reduce((acc, [k, v]) => { 163 | acc[k] = [v.length, v[0].length]; 164 | return acc; 165 | }, {}); 166 | 167 | this[wrk] = []; 168 | return new Promise((resolve, reject) => { 169 | let rejected = false; 170 | let resolveCount = this[mthrd]; 171 | const workers = _.map(_.range(this[mthrd]), () => new Worker(WORKER_PATH)); 172 | _.each(workers, (w) => { 173 | w.onmessage = (e) => { 174 | const [msg, [reason]] = e.data; 175 | if (msg === Responses.SUCCESS && rejected === false) { 176 | this[wrk].push(w); 177 | resolveCount -= 1; 178 | if (resolveCount <= 0) { 179 | resolve(this); 180 | } 181 | } else if (rejected === false) { 182 | this[wrk] = []; 183 | rejected = true; 184 | reject(reason); 185 | } 186 | }; 187 | w.postMessage([Messages.RGHST, [histogram]]); 188 | }); 189 | }); 190 | } 191 | 192 | /** 193 | * Request tile with coordinates (x, y, z). If the there is no worker available, the request 194 | * will be pushed to a LIFO queue. 195 | * 196 | * @param {number} x - The x coordinate. 197 | * @param {number} y - The y coordinate. 198 | * @param {number} z - The z coordinate (i.e., the zoom level). 199 | * @returns {string} The appropriate return code from {@link 200 | * module:rendering-client~ReturnCodes}. 201 | */ 202 | request(x, y, z, filters) { 203 | if (!( 204 | z in this[tn] && 205 | x >= 0 && x < this[tn][z][0] && 206 | y >= 0 && y < this[tn][z][1] 207 | )) { 208 | return ReturnCodes.OOB; 209 | } 210 | 211 | const id = serializeId(x, y, z, filters); 212 | const modFilter = _.filter(filters, ([name]) => name in this[fltr]); 213 | 214 | if (!_.isEqual(modFilter, this[cmod])) { 215 | // all previous requests are out of date, so we clear the cache and the queue 216 | this[cache].clear(); 217 | this[queue] = []; 218 | this[cmod] = modFilter; 219 | } else if (this[cache].has(id)) { 220 | const item = this[cache].get(id); 221 | if (item === null) { 222 | return ReturnCodes.SFW; 223 | } 224 | this.callback(item, x, y, z); 225 | return ReturnCodes.SFC; 226 | } 227 | 228 | if (this[wrk].length > 0) { 229 | const worker = this[wrk].pop(); 230 | startJob.apply(this, [id, this[data][z][x][y], worker]); 231 | } else { 232 | this[queue].push([id, this[data][z][x][y]]); 233 | } 234 | this[cache].set(id, null); 235 | return ReturnCodes.SFC; 236 | } 237 | 238 | /** 239 | * Generator that calls {@link RenderingClient#request} on all tiles within a given bounding 240 | * box. 241 | * 242 | * @param {number} xmin - The minimum x coordinate. 243 | * @param {number} ymin - The minimum y coordinate. 244 | * @param {number} xmax - The maximum x coordinate. 245 | * @param {number} ymax - The maximum y coordinate. 246 | * @param {number} z - The z coordinate (i.e., the zoom level). 247 | * @returns {object} An iterator yielding the Array [x, y, z, returnCode] for each tile within 248 | * the bounding box, where (x, y, z) is the coordinates of the tile and returnCode is the 249 | * appropriate return code from {@link module:rendering-client~ReturnCodes}. 250 | */ 251 | * requestAll(xmin, ymin, xmax, ymax, z, filters) { 252 | for (let x = xmin; x <= xmax; x += 1) { 253 | for (let y = ymin; y <= ymax; y += 1) { 254 | yield [x, y, z, this.request(x, y, z, filters)]; 255 | } 256 | } 257 | } 258 | } 259 | 260 | export default RenderingClient; 261 | export { ReturnCodes }; 262 | -------------------------------------------------------------------------------- /client/src/app/viewer/scale-manager.js: -------------------------------------------------------------------------------- 1 | /** @module scale-manager */ 2 | 3 | import _ from 'lodash'; 4 | 5 | // private members 6 | const level = Symbol('Current tile level'); 7 | const levels = Symbol('Tile levels'); 8 | 9 | /** 10 | * Manages the scaling of a canvas layer. 11 | */ 12 | class ScaleManager { 13 | constructor(tileLevels) { 14 | this[level] = Number(); 15 | this[levels] = Array(...tileLevels); 16 | this[levels] = _.sortBy(this[levels], _.id); 17 | } 18 | 19 | level(cameraLevel) { 20 | if (cameraLevel !== undefined) { 21 | let a = 1; 22 | let b = this[levels].length; 23 | while (b > a) { 24 | const c = Math.floor((a + b) / 2); 25 | if (this[levels][c] > cameraLevel) { 26 | b = c; 27 | } else { 28 | a = c + 1; 29 | } 30 | } 31 | this[level] = this[levels][a - 1]; 32 | } 33 | return this[level]; 34 | } 35 | } 36 | 37 | export default ScaleManager; 38 | -------------------------------------------------------------------------------- /client/src/app/viewer/spots.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import math from 'mathjs'; 3 | 4 | import Vec2 from './vec2'; 5 | 6 | import opacityMixin from './graphics/opacity'; 7 | import { FilledCircle } from './graphics/circle'; 8 | import { combinations, intBounds, mulVec2 } from '../utils'; 9 | import { 10 | SPOT_COL_DEF, 11 | SPOT_COL_HLT, 12 | SPOT_OPACITY_DEF, 13 | } from '../config'; 14 | 15 | 16 | // private members of Spot 17 | const sassign = Symbol('Array assignment'); 18 | const scolor = Symbol('Color getter'); 19 | const sopacity = Symbol('Opacity getter'); 20 | const sdiameter = Symbol('Spot diameter'); 21 | const srpos = Symbol('Spot render position'); 22 | const sselected = Symbol('Selected'); 23 | const sselectcb = Symbol('Selection callback'); 24 | 25 | class Spot extends opacityMixin(FilledCircle) { 26 | constructor({ 27 | position, 28 | diameter, 29 | selectcb, 30 | getcolor, 31 | getopacity, 32 | }) { 33 | super(); 34 | this.position = position; 35 | this.diameter = diameter; 36 | this[sselectcb] = selectcb; 37 | this[sselected] = false; 38 | this[sopacity] = getopacity; 39 | this[scolor] = getcolor; 40 | this[sassign] = {}; 41 | } 42 | 43 | get diameter() { return this[sdiameter]; } 44 | set diameter(value) { 45 | this[sdiameter] = value; 46 | this.r = value / 2; 47 | } 48 | 49 | get position() { return this[srpos]; } 50 | set position(value) { 51 | this[srpos] = value; 52 | this.x = value.x; 53 | this.y = value.y; 54 | } 55 | 56 | get assignment() { return this[sassign]; } 57 | set assignment({ x, y }) { this[sassign] = { x, y }; } 58 | 59 | set x(v) { this[srpos].x = v; super.x = v; } 60 | get x() { return super.x; } 61 | set y(v) { this[srpos].y = v; super.y = v; } 62 | get y() { return super.y; } 63 | 64 | get color() { return this[scolor](); } 65 | get opacity() { return this[sopacity](); } 66 | 67 | /* eslint-disable class-methods-use-this */ 68 | set color(v) { /* can't be set */ } 69 | set opacity(v) { /* can't be set */ } 70 | 71 | get fillColor() { return this.selected ? SPOT_COL_HLT : this.color; } 72 | set fillColor(value) { this.color = value; } 73 | 74 | set selected(v) { 75 | this[sselected] = v; 76 | this[sselectcb](this, v); 77 | } 78 | get selected() { return this[sselected]; } 79 | } 80 | 81 | 82 | // private members of SpotManager 83 | const sspots = Symbol('Spots'); 84 | 85 | class SpotManager { 86 | constructor() { 87 | this.mask = []; 88 | this.maskScale = 0; 89 | this.maskShape = []; 90 | this.avgDiameter = Number(); 91 | this.selected = new Set(); 92 | this[sspots] = []; 93 | this.color = SPOT_COL_DEF; 94 | this.opacity = SPOT_OPACITY_DEF; 95 | } 96 | 97 | get spotsMutable() { return this[sspots]; } 98 | get spots() { return _.cloneDeep(this[sspots]); } 99 | set spots(xs) { 100 | this[sspots].splice(0, this[sspots].length, ...xs); 101 | this.selected.clear(); 102 | _.each(xs, (x) => { 103 | if (x.selected) { 104 | this.selected.add(x); 105 | } 106 | }); 107 | } 108 | 109 | createSpot(x = 0, y = 0, d = 0) { 110 | return new Spot({ 111 | diameter: d > 0 ? d : this.avgDiameter, 112 | position: { x, y }, 113 | selectcb: (s, v) => { 114 | if (v) { 115 | this.selected.add(s); 116 | } else { 117 | this.selected.delete(s); 118 | } 119 | }, 120 | getcolor: () => this.color, 121 | getopacity: () => this.opacity, 122 | }); 123 | } 124 | 125 | loadSpots(spots, tissueMask) { 126 | const ds = _.filter(_.map(spots, _.last), x => x > 0); 127 | this.avgDiameter = math.sum(ds) / ds.length; 128 | this.spots = _.map(spots, props => this.createSpot(...props)); 129 | if (tissueMask) { 130 | this.loadMask(tissueMask); 131 | } 132 | } 133 | 134 | setSpots(spots) { 135 | this.spots = spots; 136 | } 137 | 138 | loadMask(mask) { 139 | this.mask = _.reduce( 140 | _.map( 141 | mask.data.split(''), 142 | _.flowRight( 143 | c => _.map( 144 | _.range(6, -1, -1), 145 | i => (c & (1 << i)) !== 0, 146 | ), 147 | c => c.charCodeAt(0), 148 | ), 149 | ), 150 | (a, x) => { 151 | a.push(...x); 152 | return a; 153 | }, 154 | [], 155 | ); 156 | this.maskShape = mask.shape; 157 | this.maskScale = mask.scale; 158 | } 159 | 160 | getSpots() { 161 | return { spots: this.spots, spacer: 20 }; 162 | } 163 | 164 | /** 165 | * Given a particular spot, the spot is set to selected or not depending on whether it is 166 | * located on the tissue or not. The threshold parameter determines the percentage of how 167 | * many pixels in the tissue it needs to overlap in order to be classified as being under 168 | * the tissue. 169 | */ 170 | selectTissueSpots(tmat, threshold) { 171 | // relevant only if an HE image has been uploaded 172 | // adds to current selection 173 | _.each( 174 | // ignore if already selected 175 | _.filter(this.spotsMutable, s => s.selected === false), 176 | (s) => { 177 | // transform coordinates to the basis of the mask 178 | const center = _.map( 179 | Vec2.data( 180 | mulVec2(tmat, s.position), 181 | ), 182 | x => x * this.maskScale, 183 | ); 184 | const radius = (s.diameter * this.maskScale) / 2; 185 | const inside = _.reduce( 186 | _.filter( 187 | // check all (x, y) in the bounding box of the spot 188 | combinations( 189 | ..._.map(center, (c) => { 190 | const bounds = intBounds([c], radius)[0]; 191 | return _.range(bounds[0], bounds[1] + 1); 192 | }), 193 | ), 194 | // but discard those more than `radius` away 195 | ([x, y]) => Math.sqrt( 196 | ((x - center[0]) ** 2) + ((y - center[1]) ** 2)) < radius, 197 | ), 198 | (a, [x, y]) => [a[0] + 1, a[1] + this.mask[(y * this.maskShape[0]) + x]], 199 | [0, 0], 200 | ); 201 | /* eslint-disable no-param-reassign */ 202 | s.selected = inside[1] / inside[0] > threshold; 203 | }, 204 | ); 205 | } 206 | } 207 | 208 | export default SpotManager; 209 | export { Spot }; 210 | -------------------------------------------------------------------------------- /client/src/app/viewer/undo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * undo.js 3 | * ---------------- 4 | */ 5 | 6 | import math from 'mathjs'; 7 | import $ from 'jquery'; 8 | 9 | /** 10 | * Data structure for actions which can be undone 11 | */ 12 | class UndoAction { 13 | /** 14 | * Constructs a new Undo action 15 | * 16 | * @param {string} tab - In which tab it occured 17 | * @param {string} action - The type of action 18 | * @param {object} state - The previous state associated with the action 19 | */ 20 | constructor(tab, action, state) { 21 | this.tab = tab; 22 | this.action = action; 23 | this.state = state; 24 | } 25 | } 26 | 27 | /** 28 | * Data structure for an undo stack and various utility functions associated with it 29 | */ 30 | class UndoStack { 31 | /** 32 | * Constructs a new Undo stack 33 | * 34 | */ 35 | constructor(visibility) { 36 | /** 37 | * The stack. 38 | * 39 | * @param {Object} visibility - Scope-based object which holds information on the visibility of the undo HTML buttons 40 | * @type {Array} stack - The undo stack 41 | * @type {Array} redoStack - The redo stack 42 | * @type {Array} temp - A variable to hold undo actions ready for pushing to the stack once ready (e.g. actions formed when mouse button clicked, then pushed once mouse button released) 43 | */ 44 | this.visibility = visibility; 45 | this.stack = []; 46 | this.redoStack = []; 47 | this.temp; 48 | } 49 | 50 | /** 51 | * Push to the stack. 52 | * 53 | * @param {Action} action - The action last performed. 54 | */ 55 | push(action) { 56 | /* 57 | console.log("pushing: "); 58 | console.log(action); 59 | */ 60 | // clear the redo stack if actions are being performed 61 | this.redoStack = [] 62 | this.stack.push(action); 63 | /* 64 | console.log("stack is now: "); 65 | console.log(this.stack); 66 | */ 67 | this.visibility.undo = false; 68 | this.visibility.redo = true; 69 | } 70 | 71 | /** 72 | * Pop from the stack. 73 | * 74 | * @returns {Action} action - The action last performed. 75 | */ 76 | pop() { 77 | this.redoStack.push(this.stack.slice(-1)[0]) 78 | var action = this.stack.pop() 79 | /* 80 | console.log("popping: "); 81 | console.log(this.stack); 82 | */ 83 | if(this.stack.length == 0) { 84 | this.visibility.undo = true; 85 | } 86 | this.visibility.redo = false; 87 | return action; 88 | } 89 | 90 | /** 91 | * Get the tab value of the last item in the stack. 92 | * 93 | * @returns {String} tab - The tab of the last action performed. If the stack is empty, then undefined is returned. 94 | */ 95 | lastTab() { 96 | if(this.stack.length == 0) 97 | return undefined; 98 | else 99 | return this.stack.slice(-1)[0].tab; 100 | } 101 | 102 | /** 103 | * Get the action value of the last item in the stack. 104 | * 105 | * @returns {String} action - The action of the last action performed. If the stack is empty, then undefined is returned. 106 | */ 107 | lastAction() { 108 | if(this.stack.length == 0) 109 | return undefined; 110 | else 111 | return this.stack.slice(-1)[0].action; 112 | } 113 | 114 | /** 115 | * Clear the temp variable 116 | * 117 | */ 118 | clearTemp() { 119 | //console.log("clearing temp"); 120 | this.temp = undefined; 121 | } 122 | 123 | /** 124 | * Set an undo action to the temp variable 125 | * 126 | * @param {Action} action - The action last performed. 127 | */ 128 | setTemp(action) { 129 | /* 130 | console.log("setting temp to: "); 131 | console.log(action); 132 | */ 133 | this.temp = action; 134 | } 135 | 136 | /** 137 | * Push the undo variable to the undo stack 138 | * 139 | */ 140 | pushTemp() { 141 | /* 142 | console.log("pushing temp: "); 143 | console.log(this.temp); 144 | console.log("to the stack, then clearin git"); 145 | */ 146 | this.push(this.temp); 147 | this.clearTemp(); 148 | } 149 | } 150 | 151 | export { UndoAction }; 152 | export default UndoStack; 153 | -------------------------------------------------------------------------------- /client/src/app/viewer/vec2.js: -------------------------------------------------------------------------------- 1 | // Some basic functions for x y objects 2 | const Vec2 = (function() { 3 | return { 4 | Vec2: function(x, y) { 5 | if(x == undefined) {x = 0}; 6 | if(y == undefined) {y = 0}; 7 | return {x: x, y: y}; 8 | }, 9 | copy: function(vec2) { 10 | return this.Vec2(vec2.x, vec2.y); 11 | }, 12 | clampX: function(vec2, lowerLimit, upperLimit) { 13 | return Vec2.Vec2( 14 | Math.max(lowerLimit, Math.min(upperLimit, vec2.x)), 15 | vec2.y, 16 | ); 17 | }, 18 | clampY: function(vec2, lowerLimit, upperLimit) { 19 | return Vec2.Vec2( 20 | vec2.x, 21 | Math.max(lowerLimit, Math.min(upperLimit, vec2.y)), 22 | ); 23 | }, 24 | clamp: function(vec2, lowerLimit, upperLimit) { 25 | var newVec = this.clampX(vec2, lowerLimit, upperLimit); 26 | newVec = this.clampY(newVec, lowerLimit, upperLimit); 27 | return newVec; 28 | }, 29 | truncate: function(vec2) { 30 | // truncates each value 31 | return this.Vec2(Math.trunc(vec2.x), Math.trunc(vec2.y)); 32 | }, 33 | round: function(vec2) { 34 | // rounds each value 35 | return this.Vec2(Math.round(vec2.x), Math.round(vec2.y)); 36 | }, 37 | add: function(a, b) { 38 | // adds b to a and returns a 39 | return this.Vec2(a.x + b.x, a.y + b.y); 40 | }, 41 | subtract: function(a, b) { 42 | // subtracts b from a and returns a 43 | return this.Vec2(a.x - b.x, a.y - b.y); 44 | }, 45 | multiply: function(a, b) { 46 | // multiples x elements and y elements separately 47 | return this.Vec2(a.x * b.x, a.y * b.y); 48 | }, 49 | divide: function(a, b) { 50 | // divides x elements and y elements separately 51 | return this.Vec2(a.x / b.x, a.y / b.y); 52 | }, 53 | scale: function(a, scalar) { 54 | // multiples every element in a with a scalar 55 | return this.Vec2(a.x * scalar, a.y * scalar); 56 | }, 57 | data: function(v) { 58 | return [v.x, v.y]; 59 | }, 60 | map: function(v, f) { 61 | // apply given function to each coordinate 62 | const ret = this.Vec2(); 63 | ret.x = f(v.x); 64 | ret.y = f(v.y); 65 | return ret; 66 | }, 67 | toString: function(a) { 68 | return "x: " + a.x + ", y: " + a.y; 69 | }, 70 | length: function(v) { 71 | return Math.sqrt((v.x * v.x) + (v.y * v.y)); 72 | }, 73 | angleBetween: function(a, b) { 74 | // returns angle from vector a to vector b 75 | 76 | // Check if there exists constant c so that a = cb. 77 | // If we skip this step, we may run into numerical issues, getting 78 | // dotprod / lenprod > 1, thus returning NaN. 79 | if (a.x * b.y === a.y * b.x) { 80 | return 0; 81 | } 82 | 83 | const dotprod = (a.x * b.x) + (a.y * b.y); 84 | const lenprod = this.length(a) * this.length(b); 85 | 86 | if (lenprod === 0) { 87 | return 0; 88 | } 89 | 90 | const angle = Math.acos(dotprod / lenprod); 91 | const sign = (a.x * b.y) - (a.y * b.x) > 0 ? 1 : -1; 92 | 93 | return sign * angle; 94 | }, 95 | distanceBetween: function(a, b) { 96 | // returns distance between two vectors 97 | var w = a.x - b.x; 98 | var h = a.y - b.y; 99 | return Math.sqrt(w * w + h * h); 100 | }, 101 | } 102 | })(); 103 | 104 | export default Vec2; 105 | -------------------------------------------------------------------------------- /client/src/assets/css/aligner.css: -------------------------------------------------------------------------------- 1 | #aligner input { 2 | width: auto; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/assets/css/stylesheet.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --panel-bg: var(--white); 3 | --panel-fg: var(--dark); 4 | } 5 | 6 | html { 7 | /* 8 | height: 100%; 9 | width: 100%; 10 | */ 11 | } 12 | 13 | body { 14 | background-color: #eeeeee; 15 | font-size: 0.875rem; 16 | } 17 | 18 | h4 { 19 | font-size: 1.2rem; 20 | } 21 | 22 | p { 23 | margin-bottom: 0; 24 | } 25 | 26 | p + p { 27 | margin-top: 1rem; 28 | } 29 | 30 | input[type="radio"] { 31 | display: none; 32 | } 33 | 34 | .spinner, .error-text { 35 | width: 600px; 36 | margin: 200px auto; 37 | text-align: center; 38 | } 39 | 40 | #viewer { 41 | background-color: black; 42 | } 43 | 44 | canvas:active { 45 | } 46 | 47 | .clickable { 48 | cursor: -webkit-pointer; 49 | cursor: -moz-pointer; 50 | cursor: pointer; 51 | } 52 | 53 | .fullscreen { 54 | position: absolute; 55 | width: 100%; 56 | height: 100%; 57 | } 58 | 59 | .menu-wrapper { 60 | cursor: default; 61 | position: fixed; 62 | top: 20px; 63 | left: 20px; 64 | display: flex; 65 | background-color: hsla(0, 0%, 100%, 1); 66 | border-top-left-radius: 4px; 67 | border-bottom-left-radius: 4px; 68 | border-top-right-radius: 4px; 69 | border-bottom-right-radius: 4px; 70 | } 71 | 72 | .menu-bar { 73 | background-color: hsla(0, 0%, 100%, 1); 74 | display: flex; 75 | flex-direction: column; 76 | justify-content: flex-start; 77 | border-top-left-radius: 4px; 78 | border-bottom-left-radius: 4px; 79 | border-top-right-radius: 4px; 80 | border-bottom-right-radius: 4px; 81 | } 82 | 83 | .menu-bar .btn.active { 84 | color: var(--panel-fg) !important; 85 | background-color: var(--panel-bg) !important; 86 | box-shadow: none !important; 87 | border: none !important; 88 | } 89 | 90 | .menu-bar-panel { 91 | padding: 0.5rem; 92 | color: var(--panel-fg); 93 | background-color: var(--panel-bg); 94 | display: flex; 95 | flex-direction: column; 96 | width: 248px; 97 | } 98 | 99 | .menu-bar-panel h1 { 100 | font-size: 20px; 101 | } 102 | 103 | .panel-content-wrapper { 104 | display: flex; 105 | flex-direction: column; 106 | flex-grow: 1; 107 | margin-top: 0.5rem; 108 | } 109 | 110 | .panel-content-wrapper label { 111 | margin: 0; 112 | } 113 | 114 | .panel-content-wrapper div, .panel-content-wrapper form { 115 | display: flex; 116 | flex-direction: column; 117 | justify-content: space-between; 118 | } 119 | 120 | .panel-content-wrapper div * + * { 121 | margin-top: 0.5rem; 122 | } 123 | 124 | .panel-content-wrapper .card-text, .card-title { 125 | margin: 0; 126 | } 127 | 128 | .panel-content-wrapper .grid2 { 129 | display: grid; 130 | grid-template-columns: auto auto; 131 | grid-column-gap: 0.5rem; 132 | grid-row-gap: 0.5rem; 133 | } 134 | 135 | .panel-content-wrapper .grid2 > * { 136 | margin: auto 0; 137 | } 138 | 139 | .panel-content-wrapper .btn { 140 | width: 100%; 141 | } 142 | 143 | .menu-bar-panel-title { 144 | margin-bottom: 0.5rem; 145 | } 146 | 147 | .upload-filename { 148 | display: inline-block; 149 | } 150 | 151 | .image-upload-title { 152 | display: inline-block; 153 | } 154 | 155 | .menu-bar-panel h2 { 156 | font-size: 14px; 157 | font-weight: bold; 158 | } 159 | 160 | .menu-bar-panel h3 { 161 | font-size: 14px; 162 | font-weight: bold; 163 | } 164 | 165 | .menu-bar-panel .btn-group { 166 | display: flex; 167 | flex-direction: row; 168 | } 169 | 170 | .menu-bar-panel .btn-group * + * { 171 | margin: 0; 172 | } 173 | 174 | .menu-bar-panel .card-title { 175 | padding: 0.5rem; 176 | } 177 | 178 | .menu-bar-panel .card-title > * { 179 | margin: auto 0; 180 | } 181 | 182 | .menu-bar-panel .card-text { 183 | padding: 0.5rem; 184 | } 185 | 186 | .image-toggle-bar { 187 | border: #ddd 2px solid ; 188 | position: fixed; 189 | bottom: 20px; 190 | left: 20px; 191 | -webkit-box-shadow: 2px 2px 5px 0px rgba(121, 121, 121, 0.5); 192 | -moz-box-shadow: 2px 2px 5px 0px rgba(121, 121, 121, 0.5); 193 | box-shadow: 2px 2px 5px 0px rgba(121, 121, 121, 0.5); 194 | } 195 | 196 | .image-toggle-text { 197 | margin-bottom: 0px; 198 | position: absolute; 199 | bottom: 5px; 200 | left: 5px; 201 | color: white; 202 | text-shadow: 1px 1px 2px black; 203 | } 204 | 205 | .undo-wrapper { 206 | position: fixed; 207 | top: 20px; 208 | right: 20px; 209 | } 210 | .zoom-wrapper { 211 | position: fixed; 212 | bottom: 20px; 213 | right: 20px; 214 | } 215 | 216 | .zoom-bar { 217 | -webkit-box-shadow: 2px 2px 5px 0px rgba(121, 121, 121, 0.5); 218 | -moz-box-shadow: 2px 2px 5px 0px rgba(121, 121, 121, 0.5); 219 | box-shadow: 2px 2px 5px 0px rgba(121, 121, 121, 0.5); 220 | } 221 | #button-zoom-in, #button-undo { 222 | /* not sure why it isn't this already */ 223 | border-top-right-radius: 4px; 224 | } 225 | 226 | hr { 227 | margin: 0px; 228 | } 229 | 230 | .clear { 231 | clear: both; 232 | height: 1em; 233 | } 234 | 235 | .panel-content-wrapper .spot-color-bar { 236 | flex-direction: row; 237 | width: 100%; 238 | border: #000 1px solid; 239 | } 240 | 241 | .panel-content-wrapper .spot-color { 242 | flex-grow: 1; 243 | margin: 0; 244 | height: 1.2rem; 245 | width: 1.2rem; 246 | } 247 | 248 | .panel-content-wrapper .spot-color:hover { 249 | border: 1px solid #fff; 250 | } 251 | 252 | .panel-content-wrapper .spot-color.selected { 253 | border: 1px solid #fff; 254 | } 255 | 256 | #spot-color-1 { 257 | /* red */ 258 | background-color: #E74C3C; 259 | } 260 | 261 | #spot-color-2 { 262 | /* orange */ 263 | background-color: #E67E22; 264 | } 265 | 266 | #spot-color-3 { 267 | /* yellow */ 268 | background-color: #F1C40F; 269 | } 270 | 271 | #spot-color-4 { 272 | /* green */ 273 | background-color: #2ECC71; 274 | } 275 | 276 | #spot-color-5 { 277 | /* blue */ 278 | background-color: #3498DB; 279 | } 280 | 281 | #spot-color-6 { 282 | /* purple */ 283 | background-color: #9B59B6; 284 | } 285 | 286 | #spot-opacity-1 { 287 | /* 100 */ 288 | background-color: #FFFFFF; 289 | } 290 | 291 | #spot-opacity-2 { 292 | /* 80 */ 293 | background-color: #CCCCCC; 294 | } 295 | 296 | #spot-opacity-3 { 297 | /* 60 */ 298 | background-color: #999999; 299 | } 300 | 301 | #spot-opacity-4 { 302 | /* 40 */ 303 | background-color: #666666; 304 | } 305 | 306 | #spot-opacity-5 { 307 | /* 20 */ 308 | background-color: #333333; 309 | } 310 | 311 | #spot-opacity-6 { 312 | /* 0 */ 313 | background-color: #000000; 314 | } 315 | 316 | #loading { 317 | display: flex; 318 | flex-direction: column; 319 | justify-content: center; 320 | height: 100%; 321 | width: 50%; 322 | margin: 0 auto; 323 | overflow: hidden; 324 | } 325 | 326 | #loading > canvas { 327 | width: 100%; 328 | height: 2.5rem; 329 | margin: 1rem 0; 330 | } 331 | 332 | #loading > p { 333 | display: flex; 334 | align-items: center; 335 | justify-content: space-between; 336 | margin: 1rem 10%; 337 | padding: 1rem; 338 | background: white; 339 | } 340 | -------------------------------------------------------------------------------- /client/src/assets/html/aligner.html: -------------------------------------------------------------------------------- 1 | 6 |
7 |
8 |
9 |

Layers

10 |
11 |
12 |
15 | 23 |
24 |
25 |
26 |
27 |
28 |

Tools

29 |
30 |
31 |
32 | 39 |
40 |
41 |
42 |
43 |
44 |

Adjustments

45 |
46 |
47 |
48 |
49 | 50 |
51 |

Opacity

52 | 57 |
58 |
59 |
60 | 67 | 73 |
74 |
75 | 76 | 77 |
78 |

Brightness

79 | 84 |
85 |
86 |
87 | 94 | 100 |
101 |
102 | 103 | 104 |
105 |

Contrast

106 | 111 |
112 |
113 |
114 | 121 | 127 |
128 |
129 | 130 | 131 |
132 |

Equalize

133 | 138 |
139 |
140 |
141 | 142 |
143 | 150 | 151 | 152 | 153 | 154 | 155 | 156 |
157 |
158 |
159 |
160 | -------------------------------------------------------------------------------- /client/src/assets/html/modal-button.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/html/modal.html: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /client/src/assets/images/imageToggleCy3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpatialTranscriptomicsResearch/st_spot_detector/d7869a528780e3eecb7cfdda8df11718886ba7da/client/src/assets/images/imageToggleCy3.png -------------------------------------------------------------------------------- /client/src/assets/images/imageToggleHE.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpatialTranscriptomicsResearch/st_spot_detector/d7869a528780e3eecb7cfdda8df11718886ba7da/client/src/assets/images/imageToggleHE.png -------------------------------------------------------------------------------- /client/src/worker/filters.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module filter 3 | */ 4 | 5 | /* eslint-disable no-param-reassign */ 6 | // (filters need to be applied in-place for efficiency.) 7 | 8 | class Filter { 9 | constructor() { 10 | if (new.target === Filter) { 11 | throw new TypeError( 12 | 'Call of new on abstract class Filter not allowed.'); 13 | } 14 | } 15 | 16 | /* eslint-disable class-methods-use-this */ 17 | apply(/* data, histogram, parameters */) { 18 | throw new Error('Abstract method not implemented.'); 19 | } 20 | } 21 | 22 | class Brightness extends Filter { 23 | /* eslint-disable no-plusplus */ 24 | apply(d, h, p) { 25 | if (p === 0) { 26 | return; 27 | } 28 | const k = Math.exp(-p); 29 | const brightness = v => 255 / (1 + (((255 / v) - 1) * k)); 30 | for (let i = 0; i < d.length;) { 31 | d[i] = brightness(d[i++]); 32 | d[i] = brightness(d[i++]); 33 | d[i] = brightness(d[i++]); 34 | i++; // ignore alpha channel 35 | } 36 | } 37 | } 38 | 39 | class Contrast extends Filter { 40 | /* eslint-disable no-plusplus */ 41 | apply(d, h, p) { 42 | if (p === 0) { 43 | return; 44 | } 45 | const k = Math.exp(p); 46 | const contrast = v => 255 / (1 + (((255 / v) - 1) ** k)); 47 | for (let i = 0; i < d.length;) { 48 | d[i] = contrast(d[i++]); 49 | d[i] = contrast(d[i++]); 50 | d[i] = contrast(d[i++]); 51 | i++; // ignore alpha channel 52 | } 53 | } 54 | } 55 | 56 | class Equalize extends Filter { 57 | /* eslint-disable no-plusplus */ 58 | apply(d, h, p) { 59 | if (p === false) { 60 | return; 61 | } 62 | for (let i = 0; i < d.length;) { 63 | d[i] = Math.floor(255 * h[0][d[i++]]); 64 | d[i] = Math.floor(255 * h[1][d[i++]]); 65 | d[i] = Math.floor(255 * h[2][d[i++]]); 66 | i++; // ignore alpha channel 67 | } 68 | } 69 | } 70 | 71 | export default Object.freeze({ 72 | brightness: new Brightness(), 73 | contrast: new Contrast(), 74 | equalize: new Equalize(), 75 | }); 76 | -------------------------------------------------------------------------------- /client/src/worker/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module rendering-decoder 3 | */ 4 | 5 | /* eslint-env worker */ 6 | 7 | import _ from 'lodash'; 8 | 9 | import Filters from './filters'; 10 | import { Messages, Responses } from './return-codes'; 11 | 12 | let histogram; 13 | 14 | function applyFilters(tile, filters, ...args) { 15 | _.each( 16 | _.filter(filters, _.flowRight(k => k in Filters, _.first)), 17 | ([k, v]) => { Filters[k].apply(tile.data, histogram, v); }, 18 | ); 19 | postMessage([Responses.SUCCESS, [tile, ...args]], [tile.data.buffer]); 20 | } 21 | 22 | function parseHistogram(histRaw) { 23 | const cumulative = a => _.tail(_.reduce(a, (acc, x) => acc.concat(_.last(acc) + x), [0])); 24 | const normalize = a => _.map(a, x => x / _.last(a)); 25 | const cdf = _.flowRight(normalize, cumulative); 26 | 27 | histogram = _.map(_.range(3), 28 | _.flowRight( 29 | cdf, 30 | i => histRaw.slice(i * 256, (i + 1) * 256), 31 | ), 32 | ); 33 | } 34 | 35 | onmessage = (e) => { 36 | const [msg, data] = e.data; 37 | switch (msg) { 38 | case Messages.AFLTR: 39 | applyFilters(...data); 40 | break; 41 | case Messages.GFLTR: 42 | postMessage([Responses.SUCCESS, [Object.keys(Filters)]]); 43 | break; 44 | case Messages.RGHST: 45 | parseHistogram(...data); 46 | postMessage([Responses.SUCCESS, [null]]); 47 | break; 48 | default: 49 | postMessage([Responses.FAILURE, [null]]); 50 | break; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /client/src/worker/return-codes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module worker-return-codes 3 | */ 4 | 5 | /** 6 | * Enum of messages that can be sent to the worker. 7 | */ 8 | const Messages = Object.freeze({ 9 | AFLTR: 'Apply filters', 10 | GFLTR: 'Get filters', 11 | RGHST: 'Register histogram', 12 | }); 13 | 14 | /** 15 | * Enum of response codes used by the worker. 16 | */ 17 | const Responses = Object.freeze({ 18 | SUCCESS: 0, 19 | FAILURE: 1, 20 | }); 21 | 22 | export { Messages, Responses }; 23 | -------------------------------------------------------------------------------- /client/webpack.config.devel.js: -------------------------------------------------------------------------------- 1 | const configBuilder = require('./config-builder.js'); 2 | module.exports = configBuilder(deploy = false); 3 | -------------------------------------------------------------------------------- /client/webpack.config.dist.js: -------------------------------------------------------------------------------- 1 | const configBuilder = require('./config-builder.js'); 2 | module.exports = configBuilder(deploy = true); 3 | -------------------------------------------------------------------------------- /sample_images/160906_SE_STtutorial_breast_D1_HE_AM_rotated180.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpatialTranscriptomicsResearch/st_spot_detector/d7869a528780e3eecb7cfdda8df11718886ba7da/sample_images/160906_SE_STtutorial_breast_D1_HE_AM_rotated180.jpg -------------------------------------------------------------------------------- /sample_images/160908_SE_STtutorial_Breast_D1_Cy3_AM_rotated180.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpatialTranscriptomicsResearch/st_spot_detector/d7869a528780e3eecb7cfdda8df11718886ba7da/sample_images/160908_SE_STtutorial_Breast_D1_Cy3_AM_rotated180.jpg -------------------------------------------------------------------------------- /server/app.wsgi: -------------------------------------------------------------------------------- 1 | from app import application 2 | -------------------------------------------------------------------------------- /server/app/__init__.py: -------------------------------------------------------------------------------- 1 | """ ST Spot Detector server 2 | 3 | WSGI compliant web server for the ST Spot Detector. 4 | """ 5 | 6 | from .app import APP as application 7 | -------------------------------------------------------------------------------- /server/app/__main__.py: -------------------------------------------------------------------------------- 1 | """ Runs the web server 2 | """ 3 | 4 | from os import sched_getaffinity 5 | 6 | from . import application 7 | 8 | application.run(host='0.0.0.0', port=8080, workers=len(sched_getaffinity(0))) 9 | -------------------------------------------------------------------------------- /server/app/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ Instantiates the server 3 | """ 4 | 5 | from abc import abstractmethod 6 | import asyncio 7 | from functools import reduce, wraps 8 | from io import BytesIO 9 | import itertools as it 10 | import json 11 | from operator import add 12 | import warnings 13 | 14 | import numpy as np 15 | from PIL import Image 16 | import sanic 17 | from sanic import Sanic 18 | from sanic.config import Config 19 | from tissue_recognition import recognize_tissue, get_binary_mask, free 20 | 21 | from . import imageprocessor as ip 22 | from .spots import Spots 23 | from .utils import bits_to_ascii, chunks_of 24 | 25 | 26 | MESSAGE_SIZE = 10 * (2 ** 10) ** 2 27 | TILE_SIZE = [256, 256] 28 | 29 | 30 | warnings.simplefilter('ignore', Image.DecompressionBombWarning) 31 | Image.MAX_IMAGE_PIXELS = None 32 | 33 | 34 | Config.WEBSOCKET_MAX_SIZE = 200 * (2 ** 10) ** 2 35 | 36 | sanic.handlers.INTERNAL_SERVER_ERROR_HTML = ''' 37 | Something went wrong! :( 38 | ''' 39 | 40 | APP = Sanic() 41 | APP.static('', '../client/dist') 42 | APP.static('', '../client/dist/index.html') 43 | 44 | 45 | class ClientError(RuntimeError): 46 | pass 47 | 48 | 49 | class Result(Exception): 50 | def __init__(self, result): 51 | super().__init__() 52 | self.result = result 53 | 54 | 55 | class Message(object): 56 | # pylint: disable=missing-docstring 57 | # pylint: disable=too-few-public-methods 58 | @staticmethod 59 | def _generate_header(response_type, identifier, chunks, length): 60 | return f'{response_type}:{identifier}:{chunks}:{length}' 61 | @abstractmethod 62 | def chunks(self): 63 | pass 64 | 65 | class Error(Message): 66 | # pylint: disable=missing-docstring 67 | # pylint: disable=too-few-public-methods 68 | def __init__(self, message): 69 | self.message = message 70 | def chunks(self): 71 | return [ 72 | self._generate_header('error', '', 1, len(self.message)), 73 | self.message, 74 | ] 75 | 76 | class Json(Message): 77 | # pylint: disable=missing-docstring 78 | # pylint: disable=too-few-public-methods 79 | def __init__(self, identifier, data): 80 | self.identifier = identifier 81 | self.data = json.JSONEncoder().encode(data) 82 | def chunks(self): 83 | chunks = list(map(''.join, chunks_of(MESSAGE_SIZE, list(self.data)))) 84 | return [ 85 | self._generate_header( 86 | 'json', 87 | self.identifier, 88 | len(chunks), 89 | len(self.data), 90 | ), 91 | *chunks, 92 | ] 93 | 94 | class Status(Message): 95 | # pylint: disable=missing-docstring 96 | # pylint: disable=too-few-public-methods 97 | def __init__(self, status): 98 | self.status = status 99 | def chunks(self): 100 | return [ 101 | self._generate_header('status', '', 1, len(self.status)), 102 | self.status, 103 | ] 104 | 105 | 106 | def _async_request(route): 107 | def _decorator(fnc): 108 | @APP.websocket(route) 109 | @wraps(fnc) 110 | async def _wrapper(_req, socket, *args, **kwargs): 111 | async def _receive(): 112 | async for header in socket: 113 | if header == '\0': 114 | break 115 | [type_, identifier, chunks] = header.split(':') 116 | data = [await socket.recv() for _ in range(int(chunks))] 117 | yield [type_, identifier, reduce(add, data)] 118 | async def _send(message): 119 | for chunk in message.chunks(): 120 | await socket.send(chunk) 121 | try: 122 | async for message in fnc(_receive(), *args, **kwargs): 123 | await _send(message) 124 | except ClientError as err: 125 | await _send(Error(str(err))) 126 | await socket.send('\0') 127 | await socket.recv() 128 | return _wrapper 129 | return _decorator 130 | 131 | 132 | def _get_spots(img, scale_factor, array_size): 133 | bct_image = ip.apply_bct(img) 134 | keypoints = ip.detect_keypoints(bct_image) 135 | spots = Spots(array_size, scale_factor) 136 | try: 137 | spots.create_spots_from_keypoints(keypoints, bct_image) 138 | except RuntimeError: 139 | raise ClientError('Spot detection failed.') 140 | return spots.wrap_spots() 141 | 142 | 143 | def _get_tissue_mask(image): 144 | image = np.array(image, dtype=np.uint8) 145 | mask = np.zeros(image.shape[0:2], dtype=np.uint8) 146 | recognize_tissue(image, mask) 147 | mask = get_binary_mask(mask) 148 | bit_string = bits_to_ascii((mask == 255).flatten()) 149 | free(mask) 150 | return bit_string 151 | 152 | 153 | @_async_request('/run') 154 | async def run(packages, loop=None): 155 | """ Tiles the received images and runs spot and tissue detection 156 | """ 157 | if loop is None: 158 | loop = asyncio.get_event_loop() 159 | execute = lambda f, *args: \ 160 | loop.run_in_executor(None, f, *args) 161 | 162 | array_size = [] 163 | 164 | async def _do_image_tiling(image): 165 | tilemap = dict() 166 | tilemap_sizes = list(it.takewhile( 167 | lambda x: all([a > b for a, b in zip(x[1], TILE_SIZE)]), 168 | ((l, [x / l for x in image.size]) 169 | for l in (2 ** k for k in it.count())), 170 | )) 171 | cur = image 172 | for i, (l, s) in enumerate(tilemap_sizes): 173 | yield l, i + 1, len(tilemap_sizes) 174 | cur = await execute(lambda: cur.resize(list(map(int, s)))) 175 | tilemap[l] = await loop.run_in_executor( 176 | None, ip.tile_image, cur, *TILE_SIZE) 177 | raise Result(dict( 178 | image_size=image.size, 179 | tiles=tilemap, 180 | tile_size=TILE_SIZE, 181 | )) 182 | 183 | async def _do_spot_detection(image): 184 | if not (isinstance(array_size, list) 185 | and len(array_size) == 2 186 | and all([x > 0 for x in array_size])): 187 | raise ClientError('Invalid array size') 188 | image, scale = await execute(ip.resize_image, image, [4000, 4000]) 189 | return await execute(_get_spots, image, scale, array_size) 190 | 191 | async def _do_mask_detection(image): 192 | image, scale = await execute(ip.resize_image, image, [500, 500]) 193 | return dict( 194 | data=await execute(_get_tissue_mask, image), 195 | shape=image.size, 196 | scale=1 / scale, 197 | ) 198 | 199 | async def _process_image(identifier, image_data): 200 | try: 201 | yield Status(f'Inflating {identifier} image data') 202 | image = await execute(lambda: Image.open(BytesIO(image_data))) 203 | except: 204 | raise ClientError( 205 | 'Invalid image format. Please upload only jpeg images.') 206 | try: 207 | async for level, i, n in _do_image_tiling(image): 208 | yield Status(f'Tiling {identifier}: level {level} ({i}/{n})') 209 | except Result as r: 210 | yield Status(f'Computing {identifier} histogram') 211 | r.result.update(histogram=await execute(image.histogram)) 212 | yield Json(identifier, r.result) 213 | 214 | if identifier == 'Cy3': 215 | yield Status('Detecting spots') 216 | yield Json('spots', await _do_spot_detection(image)) 217 | elif identifier == 'HE': 218 | yield Status('Computing tissue mask') 219 | yield Json('mask', await _do_mask_detection(image)) 220 | 221 | async for [type_, identifier, data] in packages: 222 | if type_ == 'image': 223 | async for response in _process_image(identifier, data): 224 | yield response 225 | elif type_ == 'json_string' and identifier == 'array_size': 226 | array_size = json.JSONDecoder().decode(data) 227 | -------------------------------------------------------------------------------- /server/app/circle_detector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from enum import Enum 4 | 5 | import numpy as np 6 | 7 | class DetectionType(Enum): 8 | EDGES = 1 9 | WHITENESS = 2 10 | 11 | class CircleDetector: 12 | """Contains functions which returns circles found at given positions in a 13 | given pixel array. 14 | """ 15 | 16 | # pylint: disable=bad-whitespace 17 | __directions = [ 18 | ( 1.0, 0.0), 19 | ( 1.0, 1.0), 20 | ( 0.0, 1.0), 21 | (-1.0, 1.0), 22 | (-1.0, 0.0), 23 | (-1.0, -1.0), 24 | ( 0.0, -1.0), 25 | ( 1.0, -1.0) 26 | ] 27 | __pairs = [(0, 4), (1, 5), (2, 6), (3, 7)] 28 | 29 | __edge_search_radius = 50 30 | __white_search_radius = 20 31 | 32 | __whiteness_threshold = 200.0 33 | 34 | def detect_spot(self, detection_type, image_pixels, position): 35 | """Takes a position and image pixels and uses either whiteness 36 | detection or circle-detection to determine if a spot is there 37 | or not. 38 | Returns the corresponding detected spot positions or None if no 39 | spot is found there. 40 | """ 41 | spot = None 42 | if(detection_type == DetectionType.EDGES): 43 | spot_edges = self.__edges_at_position( 44 | image_pixels, position, self.__edge_search_radius) 45 | spot = self.__circle_from_edges(spot_edges) 46 | elif(detection_type == DetectionType.WHITENESS): 47 | spot = self.__circle_from_whitness(image_pixels, position) 48 | else: 49 | raise RuntimeError('Invalid detection_type parameter;\ 50 | must be DetectionType') 51 | return spot 52 | 53 | def detect_spots(self, detection_type, image_pixels, positions): 54 | """Takes an array of positions and image pixels and uses either 55 | whiteness detection or circle-detection to determine if a spot is 56 | there or not. 57 | Returns an array of the corresponding detected spot positions or 58 | None, if no spot is found there. 59 | """ 60 | spot_array = [] 61 | for position in positions: 62 | spot = self.detect_spot(detection_type, image_pixels, position) 63 | spot_array.append(spot) 64 | return spot_array 65 | 66 | 67 | def __distance_between(self, a, b): 68 | """Takes array-like objects and calculates the distances between 69 | them using numpy. 70 | Returns a float. 71 | """ 72 | dist = np.linalg.norm(np.array(a) - np.array(b)) 73 | return dist 74 | 75 | def __get_surrounding_pixels(self, position, radius, circle=True): 76 | """Returns an array with the positions of pixels surrounding 77 | a particular point at a given radius from it. 78 | """ 79 | # will return out-of-bound pixels, may need to fix this if 80 | # spots are close to the image edge 81 | pixels = [] 82 | for y in range(int(position[1] - radius), int(position[1] + radius)): 83 | for x in range(int(position[0] - radius), int(position[0] + radius)): 84 | pixel = (x, y) 85 | # this check ensures pixels are in a circle 86 | if(self.__distance_between(pixel, position) < radius): 87 | pixels.append(pixel) 88 | 89 | return pixels 90 | 91 | def __get_pixel_pos_array(self, position, direction, length): 92 | """returns an array of positions from a certain position 93 | in a certain direction for a certain number 94 | of pixels (length) 95 | """ 96 | pixels = [] 97 | for i in range(length): 98 | pixel = ( 99 | position[0] + i * direction[0], 100 | position[1] + i * direction[1] 101 | ) 102 | pixels.append(pixel) 103 | 104 | return pixels 105 | 106 | def __intensity_at(self, image_pixels, position): 107 | try: 108 | r, _g, _b = image_pixels[position[0], position[1]] 109 | except IndexError as _: 110 | return None 111 | return r 112 | 113 | def __bb_from_points(self, points): 114 | """Returns a bounding box containing all the points.""" 115 | min_x = min(points, key=lambda point: (point[0]))[0] 116 | max_x = max(points, key=lambda point: (point[0]))[0] 117 | min_y = min(points, key=lambda point: (point[1]))[1] 118 | max_y = max(points, key=lambda point: (point[1]))[1] 119 | return(min_x, min_y, max_x, max_y) 120 | 121 | def __edge_detected(self, image_pixels, position, direction, search_radius): 122 | """Crawls along a line of pixels at a given position and direction 123 | and determines whether there is a sudden intensity increase 124 | which may be spot edge. 125 | """ 126 | pixels = self.__get_pixel_pos_array(position, direction, search_radius) 127 | intensities = list(filter( 128 | lambda x: x is not None, 129 | [self.__intensity_at(image_pixels, pixel) for pixel in pixels], 130 | )) 131 | averages = [] 132 | differences = [] 133 | for i, intensity in enumerate(intensities): 134 | # these averages check for the last four backwards; 135 | # probably want to makke them check for ones in front, too 136 | intensity = float(intensity) 137 | if(i == 0): 138 | continue 139 | elif(i == 1): 140 | intensity2 = float(intensities[i-1]) 141 | averages.append((intensity + intensity2)/2.0) 142 | elif(i == 2): 143 | intensity2 = float(intensities[i-1]) 144 | intensity3 = float(intensities[i-2]) 145 | averages.append((intensity + intensity2 + intensity3)/3.0) 146 | else: 147 | intensity2 = float(intensities[i-1]) 148 | intensity3 = float(intensities[i-2]) 149 | intensity4 = float(intensities[i-3]) 150 | averages.append((intensity + intensity2 + intensity3 + intensity4)/4.0) 151 | 152 | for i, average in enumerate(averages): 153 | if(i == 0): 154 | continue 155 | differences.append(average - averages[i - 1]) 156 | 157 | peak, index = max([(difference, i) for i, difference in enumerate(differences)]) 158 | difference_mean = np.mean(differences) 159 | difference_std = np.std(differences) 160 | 161 | if(peak > difference_mean + 2 * difference_std and difference_mean > 0): 162 | # also need to check if outlier 163 | peak = ( 164 | position[0] + (index + 2) * direction[0], 165 | position[1] + (index + 2) * direction[1] 166 | ) 167 | else: 168 | #print("No peak.") 169 | peak = None 170 | 171 | return peak 172 | 173 | def __edges_at_position(self, image_pixels, position, search_radius): 174 | """Given an image array and a position in the image, determines if 175 | there is are edges located there at certain directions out from the 176 | given position. 177 | Returns an array of edges corresponding to the directions searched. 178 | """ 179 | edges = [self.__edge_detected(image_pixels, position, direction, search_radius) for direction in self.__directions] 180 | 181 | for pair in self.__pairs: 182 | edge_a = edges[pair[0]] 183 | edge_b = edges[pair[1]] 184 | if(not edge_a or not edge_b): 185 | continue 186 | 187 | edge_a = np.array(edge_a) 188 | edge_b = np.array(edge_b) 189 | 190 | dist = self.__distance_between(edge_a, edge_b) 191 | if(dist > 58): # quite a "hardcoded" value: will not work as well for stretched images 192 | #print("Discarding a point! It is too far away") 193 | dist_a = self.__distance_between(position, edge_a) 194 | dist_b = self.__distance_between(position, edge_b) 195 | if(dist_a > dist_b): 196 | edges[pair[0]] = None 197 | else: 198 | edges[pair[1]] = None 199 | 200 | edges = [edge for edge in edges if edge is not None] # remove all "None"s 201 | return edges 202 | 203 | def __circle_from_edges(self, edges): 204 | """Given an array of edges, determine if they form a circle or not 205 | based on the distances of the edges from their centre. 206 | Returns the circle centre if one is found, otherwise returns None. 207 | """ 208 | 209 | if(len(edges) < 4): # not enough edges to be a circle 210 | #print("Not enough edges.") 211 | return None 212 | 213 | b_box = self.__bb_from_points(edges) 214 | #print(edges) 215 | #print(b_box) 216 | centre = ( 217 | (b_box[0] + b_box[2]) / 2.0, 218 | (b_box[1] + b_box[3]) / 2.0 219 | ) 220 | #print(centre) 221 | # distances of the edges from the centre 222 | radii = [self.__distance_between(centre, edge) for edge in edges] 223 | radius_mean = np.mean(radii) 224 | radius_std = np.std(radii) 225 | 226 | if(radius_std < radius_mean * 0.20): 227 | #print("It seems to be a valid circle!") 228 | return centre 229 | else: 230 | #print("Points not circley enough.") 231 | #print("the std radius is %f and there is the mean %f, which is higher than the condition of %f" %(radius_std, radius_mean, radius_mean * 0.20)) 232 | return None 233 | 234 | def __circle_from_whitness(self, image_pixels, position): 235 | whiteness = 0 236 | pixels = self.__get_surrounding_pixels(position, self.__white_search_radius) 237 | for pixel in pixels: 238 | intensity = self.__intensity_at(image_pixels, pixel) 239 | if intensity is not None: 240 | whiteness += intensity 241 | 242 | whiteness_avg = float(whiteness) / float(len(pixels)) 243 | if(whiteness_avg < self.__whiteness_threshold): 244 | return position 245 | else: 246 | return None 247 | -------------------------------------------------------------------------------- /server/app/imageprocessor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Methods related to image processing.""" 3 | 4 | from base64 import b64encode 5 | from io import BytesIO 6 | import warnings 7 | 8 | import cv2 9 | from PIL import Image, ImageOps 10 | import numpy 11 | 12 | 13 | warnings.simplefilter('ignore', Image.DecompressionBombWarning) 14 | Image.MAX_IMAGE_PIXELS = None 15 | 16 | 17 | def tile_image(image, tile_width, tile_height): 18 | """Takes a jpeg image, scales its size down and splits it up 19 | into tiles. 20 | 21 | A 2D array of tiles is returned. 22 | """ 23 | tiles = [] 24 | 25 | tile_size = [tile_width, tile_height] 26 | # width and height of the tilemap (ints) 27 | tilemap_size = [ 28 | image.size[0] // tile_size[0] + 1, 29 | image.size[1] // tile_size[1] + 1 30 | ] 31 | 32 | for x in range(0, tilemap_size[0]): 33 | new_row = [] 34 | for y in range(0, tilemap_size[1]): 35 | crop_start = [ 36 | tile_size[0] * x, 37 | tile_size[1] * y 38 | ] 39 | crop_stop = [ 40 | crop_start[0] + tile_size[0], 41 | crop_start[1] + tile_size[1] 42 | ] 43 | 44 | cropped_image = image.crop( 45 | tuple(crop_start + crop_stop)) 46 | 47 | data = BytesIO() 48 | cropped_image.save(data, 'JPEG', quality=100) 49 | new_row.append( 50 | 'data:image/jpeg;base64,' 51 | f'{b64encode(data.getvalue()).decode()}' 52 | ) 53 | tiles.append(new_row) 54 | 55 | return tiles 56 | 57 | def apply_bct(image, apply_thresholding=True): 58 | """Performs colour invert on a PIL image, then converts it to an OpenCV 59 | image and applies automatic brightness and contrast equalisation (CLAHE) and 60 | thresholding, then converts it back and returns a processed PIL Image. 61 | """ 62 | # the image is inverted to colour the features darkest 63 | image = ImageOps.invert(image) 64 | 65 | # convert the image into a grayscale 66 | image = numpy.array(image.convert('L')) 67 | 68 | # create a CLAHE object 69 | clahe = cv2.createCLAHE(clipLimit=20.0, tileGridSize=(1, 1)) 70 | 71 | image = clahe.apply(image) 72 | 73 | if apply_thresholding: 74 | # Mean adaptive threshold has been chosen here because Gaussian 75 | # adaptive thresholding is very slow, takes about 15 minutes for a 76 | # 20k x 20k image and does not yield significantly better results 77 | image = cv2.adaptiveThreshold(image, 255, 78 | cv2.ADAPTIVE_THRESH_MEAN_C, 79 | cv2.THRESH_BINARY, 80 | 103, 20) 81 | 82 | return Image.fromarray(image).convert('RGB') 83 | 84 | 85 | def detect_keypoints(image): 86 | """This function uses OpenCV to threshold an image and do some simple, 87 | automatic blob detection and returns the keypoints generated. 88 | The parameters for min and max area are roughly based on an image 89 | of size 4k x 4k. 90 | """ 91 | image = numpy.array(image.convert('L')) 92 | 93 | params = cv2.SimpleBlobDetector_Params() 94 | params.thresholdStep = 5.0 95 | params.minThreshold = 170.0 96 | params.maxThreshold = params.minThreshold + 50.0 97 | params.filterByArea = True 98 | params.minArea = 800 99 | params.maxArea = 5000 100 | 101 | detector = cv2.SimpleBlobDetector_create(params) 102 | keypoints = detector.detect(image) 103 | 104 | return keypoints 105 | 106 | def resize_image(image, max_size): 107 | """Resize and return an image and the scaling factor, defined as the 108 | original image size / resultant image size. 109 | The aspect ratio is preserved. 110 | """ 111 | width, height = image.size 112 | aspect_ratio = float(width) / float(height) 113 | if aspect_ratio >= 1.0: 114 | new_width = max_size[0] 115 | new_height = int(float(new_width) / aspect_ratio) 116 | else: 117 | new_height = max_size[1] 118 | new_width = int(float(new_height) * aspect_ratio) 119 | 120 | scaling_factor = float(width) / float(new_width) 121 | 122 | return image.resize((new_width, new_height), Image.ANTIALIAS), scaling_factor 123 | -------------------------------------------------------------------------------- /server/app/logger.py: -------------------------------------------------------------------------------- 1 | """ Provides utility functions for logging 2 | """ 3 | 4 | import logging as L 5 | from logging.config import dictConfig 6 | 7 | import sys 8 | 9 | WARNING = L.WARNING 10 | INFO = L.INFO 11 | 12 | dictConfig(dict( 13 | version=1, 14 | disable_existing_loggers=False, 15 | loggers={ 16 | 'server': { 17 | 'level': 'INFO', 18 | 'handlers': ['console'] 19 | }, 20 | }, 21 | handlers={ 22 | 'console': { 23 | 'class': 'logging.StreamHandler', 24 | 'formatter': 'default', 25 | 'stream': sys.stderr 26 | }, 27 | }, 28 | formatters={ 29 | 'default': { 30 | 'format': '%(asctime)s %(levelname)s %(message)s', 31 | 'datefmt': '[%Y-%m-%d %H:%M:%S %z]', 32 | 'class': 'logging.Formatter' 33 | }, 34 | }, 35 | )) 36 | 37 | LOGGER = L.getLogger('server') 38 | 39 | log = LOGGER.log # pylint: disable=invalid-name 40 | -------------------------------------------------------------------------------- /server/app/spots.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import itertools as it 4 | 5 | import numpy as np 6 | from sklearn.cluster import KMeans 7 | 8 | from .circle_detector import CircleDetector, DetectionType 9 | 10 | 11 | CIRCLE_DETECTOR = CircleDetector() 12 | 13 | 14 | class Spots: 15 | """Holds the spot data. These spots are stored with positions relative 16 | to the originally-uploaded Cy3 image. 17 | """ 18 | # pylint: disable=invalid-name 19 | 20 | def __init__(self, array_size, scaling_factor): 21 | self.spots = [] 22 | self.tissue_spots = [] 23 | self.array_size = array_size 24 | self.scaling_factor = scaling_factor 25 | self.tl = [0, 0] 26 | self.br = [0, 0] 27 | 28 | def wrap_spots(self): 29 | """Wraps detected spot positions and calibration coordinates in dict""" 30 | return { 31 | 'positions': self.spots.tolist(), 32 | 'tl': self.tl.tolist(), 33 | 'br': self.br.tolist(), 34 | } 35 | 36 | def create_spots_from_keypoints(self, keypoints, thresholded_image): 37 | """Takes keypoints generated from opencv spot detection and 38 | tries to match them to their correct array positions. 39 | It also tries to fill in "missing spots" by finding which array 40 | positions do not have a corresponding keypoint, then analyses the 41 | pixels around that position to determine if a spot is likely 42 | to be there or not. 43 | """ 44 | if len(keypoints) < max(*self.array_size): 45 | raise RuntimeError('Too few keypoints') 46 | 47 | spots = np.array(list(zip(*[x.pt + (x.size,) for x in keypoints]))) 48 | 49 | xmeans, ymeans = [ 50 | np.sort([np.mean(zs[c == k]) for k in range(n)]) 51 | for zs, n, c in zip( 52 | spots, 53 | self.array_size, 54 | [ 55 | KMeans(n_clusters=k).fit_predict(np.transpose([zs])) 56 | for k, zs in zip(self.array_size, spots) 57 | ] 58 | ) 59 | ] 60 | 61 | def _bin_search(xs, x): 62 | def _do(l, h): 63 | if h - l == 1: 64 | return l, h 65 | m = (h + l) // 2 66 | if xs[m] >= x: 67 | return _do(l, m) 68 | return _do(m, h) 69 | l, h = _do(0, len(xs) - 1) 70 | return h if xs[h] - x < x - xs[l] else l 71 | 72 | bins = np.zeros(self.array_size) 73 | for x, y, _ in zip(*spots): 74 | bins[_bin_search(xmeans, x), _bin_search(ymeans, y)] += 1 75 | 76 | missing_spots = [] 77 | for x, y in it.product(*map(range, self.array_size)): 78 | if bins[x, y] == 0: 79 | missing_spots.append((x, y)) 80 | 81 | image_pixels = thresholded_image.load() 82 | 83 | for x, y in missing_spots: 84 | for res in ( 85 | CIRCLE_DETECTOR.detect_spot( 86 | t, 87 | image_pixels, 88 | (xmeans[x], ymeans[y]), 89 | ) for t in ( 90 | DetectionType.EDGES, 91 | DetectionType.WHITENESS, 92 | ) 93 | ): 94 | if res is not None: 95 | spots = np.concatenate([ 96 | spots, 97 | np.transpose([res + (0,)]), 98 | ], axis=1) 99 | break 100 | 101 | self.spots = np.transpose(spots) * self.scaling_factor 102 | self.tl, self.br = np.transpose([ 103 | xmeans[[0, -1]], 104 | ymeans[[0, -1]], 105 | ]) * self.scaling_factor 106 | -------------------------------------------------------------------------------- /server/app/utils.py: -------------------------------------------------------------------------------- 1 | """utility functions""" 2 | 3 | 4 | from functools import reduce 5 | 6 | import numpy as np 7 | 8 | 9 | def chunks_of(length, list_): 10 | """ 11 | Splits list into sublists, each of length n. If the length of the list is 12 | not divisible by n, the last sublist will have length len(list) % n. The 13 | result is provided as a generator. 14 | 15 | >>> list(chunks_of(3, list(range(10)))) 16 | [[0, 1, 2], [3, 4, 5], [6, 7, 8], [9]] 17 | """ 18 | # convert to numpy array first, since slicing in numpy is O(1) 19 | list_ = np.array(list_) 20 | while list_.size > 0: 21 | yield list_[:length].tolist() 22 | list_ = list_[length:] 23 | 24 | 25 | def bits_to_int(list_): 26 | """ 27 | Converts a big-endian bit string to its integer representation. 28 | 29 | >>> bits_to_int([1, 0, 1]) 30 | 5 31 | """ 32 | return reduce( 33 | lambda a, x: a | (1 << x[1]) if x[0] == 1 else a, 34 | zip(list_, range(len(list_) - 1, -1, -1)), 35 | 0, 36 | ) 37 | 38 | def bits_to_ascii(list_): 39 | """ 40 | Converts a big-endian bit string to its ASCII-representation. 41 | 42 | >>> bits_to_ascii([1,0,0,1,0,0,0,1,1,0,0,1,0,1,1,1,1,1,0,0,1,0,1,0,0,0,0,1]) 43 | 'Hey!' 44 | """ 45 | return reduce( 46 | lambda a, x: a + chr(x), 47 | map(bits_to_int, chunks_of(7, list_)), 48 | '', 49 | ) 50 | 51 | def equal(list_): 52 | """ 53 | Returns True iff all the elements in a list are equal. 54 | 55 | >>> equal([1,1,1]) 56 | True 57 | """ 58 | return reduce( 59 | lambda a, x: a and (x[0] == x[1]), 60 | zip(list_[1:], list_[:-1]), 61 | True, 62 | ) 63 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | numpy==1.14.* 2 | opencv-python==3.4.* 3 | Pillow==5.1.* 4 | Sanic==0.8.* 5 | scikit-learn==0.18.* 6 | scipy==1.0.* 7 | --------------------------------------------------------------------------------