From 55bc03a57981cda05b5813e9cab6c4b2f1c1f05c Mon Sep 17 00:00:00 2001 From: Nhan Huynh Date: Mon, 15 May 2023 22:57:08 -0400 Subject: [PATCH 1/7] added spatial selection (single, additive and subtractive) --- minerva_analysis/client/src/css/main.css | 14 ++ minerva_analysis/client/src/js/main.js | 11 +- .../client/src/js/services/dataLayer.js | 22 ++ .../client/src/js/views/imageViewer.js | 197 +++++++++++++++++- minerva_analysis/client/templates/base.html | 8 + minerva_analysis/server/models/data_model.py | 35 ++++ minerva_analysis/server/routes/data_routes.py | 8 + .../server/utils/smallestenclosingcircle.py | 126 +++++++++++ requirements.yml | 1 + 9 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 minerva_analysis/server/utils/smallestenclosingcircle.py diff --git a/minerva_analysis/client/src/css/main.css b/minerva_analysis/client/src/css/main.css index 38dd68a39..abe0dcc42 100644 --- a/minerva_analysis/client/src/css/main.css +++ b/minerva_analysis/client/src/css/main.css @@ -464,3 +464,17 @@ label { .col-svg-wrapper { } + +#selectionPolygon { + fill-opacity: 0; + stroke: orange; + stroke-width: 3px; + vector-effect: non-scaling-stroke; +} + +#lasso-toggle { + float: right; + padding-top: 0; + padding-bottom: 0; + border: 1px; +} diff --git a/minerva_analysis/client/src/js/main.js b/minerva_analysis/client/src/js/main.js index 60721d7b9..f65b47e7f 100644 --- a/minerva_analysis/client/src/js/main.js +++ b/minerva_analysis/client/src/js/main.js @@ -65,7 +65,7 @@ async function init(config) { //Create image viewer const imageArgs = [imgMetadata, numericData, eventHandler]; - const seaDragonViewer = new ImageViewer(config, ...imageArgs); + const seaDragonViewer = new ImageViewer(config, dataLayer, ...imageArgs); const viewerManager = new ViewerManager(seaDragonViewer, channelList); //Initialize with database description @@ -166,12 +166,21 @@ async function init(config) { seaDragonViewer.forceRepaint(); } + /** + * Add picked cell ids from lasso selection + */ + const imageLassoSel = (d) => { + updateSeaDragonSelection(d); + }; + eventHandler.bind(ImageViewer.events.imageLassoSel, imageLassoSel); + /** * Remove currently selected picked cell ids */ function clearSeaDragonSelection() { updateSeaDragonSelection({ picked: [] }); } + eventHandler.bind(ImageViewer.events.clearImageLasso, clearSeaDragonSelection); const handler = () => updateSeaDragonSelection(); eventHandler.bind(CSVGatingList.events.GATING_BRUSH_END, handler); diff --git a/minerva_analysis/client/src/js/services/dataLayer.js b/minerva_analysis/client/src/js/services/dataLayer.js index 853e3ae27..b0f52bebe 100644 --- a/minerva_analysis/client/src/js/services/dataLayer.js +++ b/minerva_analysis/client/src/js/services/dataLayer.js @@ -557,4 +557,26 @@ class DataLayer { return false; } + async getCellsInPolygon(points) { + try { + let response = await fetch('/get_cells_in_polygon', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify( + { + datasource: datasource, + points: points, + } + ) + }); + let cells = await response.json(); + return cells; + } catch (e) { + console.log("Error Getting Polygon Cells", e); + } + } + } diff --git a/minerva_analysis/client/src/js/views/imageViewer.js b/minerva_analysis/client/src/js/views/imageViewer.js index 73057f6b0..22d09ebf8 100644 --- a/minerva_analysis/client/src/js/views/imageViewer.js +++ b/minerva_analysis/client/src/js/views/imageViewer.js @@ -20,9 +20,10 @@ class ImageViewer { * @param numericData - custom numeric data layer * @param eventHandler - the event handler for distributing interface and data updates */ - constructor(config, imgMetadata, numericData, eventHandler) { + constructor(config, dataLayer, imgMetadata, numericData, eventHandler) { this.ready = false; this.config = config; + this.dataLayer = dataLayer; this.gatingList = null; this.channelList = null; this.imgMetadata = imgMetadata; @@ -31,6 +32,12 @@ class ImageViewer { this.pickingChanged = false; this._cacheKeys = {}; this._picking = []; + this.pickedId = -1; + this.lasso_toggle = false; + this.lasso_ids = {}; + this.lasso_ids_subtact = {}; + this.lasso_init = false; + this.toggle_bool = true; // Viewer this.viewer = {}; @@ -81,6 +88,7 @@ class ImageViewer { // Instantiate viewer with the ViaWebGL Version of OSD this.viewer = viaWebGL.OpenSeadragon(viewer_config); this.addScaleBar(); + this.selectionPolygonToDraw = []; // Get and shrink all button images this.parent = d3.select(`#openseadragon`); @@ -376,6 +384,164 @@ class ImageViewer { } }); } + }); + + //SELECTION POLYGON (LASSO) + let that = this; + that.svg_overlay = that.viewer.svgOverlay() + that.overlay = d3.select(that.svg_overlay.node()) + + that.polygonSelection = []; + that.renew = false; + that.numCalls = 0; //defines how fine-grained the polygon resolution is (0 = no subsampling, 10=high subsampling) + that.lassoing = false; + that.isSelectionToolActive = true; + + that.lasso_draw = function (event) { + //add points to polygon and (re)draw + let webPoint = event.position; + if (that.numCalls % 5 == 0) { + // Convert that to viewport coordinates, the lingua franca of OpenSeadragon coordinates. + let viewportPoint = that.viewer.viewport.pointFromPixel(webPoint); + // Convert from viewport coordinates to image coordinates. + let imagePoint = that.viewer.world.getItemAt(0).viewportToImageCoordinates(viewportPoint); + const zoomScale = 2**config.extraZoomLevels; + imagePoint = {x:imagePoint.x/zoomScale, y:imagePoint.y/zoomScale} + that.polygonSelection.push({'imagePoints': imagePoint, 'viewportPoints': viewportPoint}); + } + + d3.select('#selectionPolygon').remove(); + var selPoly = that.overlay.selectAll("selectionPolygon").data([that.polygonSelection]); + selPoly.enter().append("polygon") + .attr('id', 'selectionPolygon') + .attr("points", function (d) { + return d.map(function (d) { + return [d.viewportPoints.x, d.viewportPoints.y].join(","); + }).join(" "); + }) + + that.numCalls++; + } + + that.lasso_end = function (event) { + that.renew = true; + } + + let primaryTracker = new OpenSeadragon.MouseTracker({ + element: that.viewer.canvas, + pressHandler: (event) => { + if (event.originalEvent.shiftKey) { + this.viewer.setMouseNavEnabled(false); + if (that.isSelectionToolActive) { + that.lassoing = true; + } else { + } + if (!that.isSelectionToolActive) { + d3.select('#selectionPolygon').remove(); + + } + that.polygonSelection = []; + that.numCalls = 0; + } + }, releaseHandler: (event) => { + this.viewer.setMouseNavEnabled(true); + if (that.lassoing) { + that.lassoing = false; + console.log('release'); + if (that.isSelectionToolActive) { + that.lasso_end(event); + if (_.size(that.polygonSelection) > 2) { + // that.showLoader(); + return dataLayer.getCellsInPolygon(that.polygonSelection) + .then(packet =>{ + this.lasso_toggle = true; + this.lasso_ids = packet['list_ids']; + this.lasso_ids_subtact = packet['list_ids_subtract']; + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); + if(!this.lasso_init){ + $('#gating_list_ul').prepend("
" + + "Lasso Selection" + + "" + + "
" + ); + document.getElementById('lasso-btn').addEventListener("click", e => { + return this.toggleLasso(); + }) + const toggle_lasso = document.querySelector("#lasso-toggle"); + this.toggle_bool = true; + toggle_lasso.addEventListener("click", (e) => { + if(this.toggle_bool){ + this.toggle_bool = false; + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids_subtact}); + } else{ + this.toggle_bool = true; + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); + } + }); + toggle_lasso.addEventListener("click", e => e.stopPropagation()); + this.lasso_init = true; + } + }) + } + } + } + }, nonPrimaryReleaseHandler(event) { + if (that.selectButton.classList.contains('selected') && !that.lassoing) { + const webPoint = event.position; + // Convert that to viewport coordinates, the lingua franca of OpenSeadragon coordinates. + const viewportPoint = that.viewer.viewport.pointFromPixel(webPoint); + // Convert from viewport coordinates to image coordinates. + let imagePoint = that.viewer.world.getItemAt(0).viewportToImageCoordinates(viewportPoint); + const zoomScale = 2**config.extraZoomLevels; + imagePoint = {x:imagePoint.x/zoomScale, y:imagePoint.y/zoomScale} + return that.dataLayer.getNearestCell(imagePoint.x, imagePoint.y) + .then(selectedItem => { + if (selectedItem !== null && selectedItem !== undefined) { + // Check if user is doing multi-selection or not + let clearPriors = true; + if (event.originalEvent.ctrlKey) { + clearPriors = false; + } + // Trigger event + that.eventHandler.trigger(ImageViewer.events.imageClickedMultiSel, { + selectedItem, + clearPriors + }); + } + }) + } + } + , moveHandler: function (event) { + if (that.isSelectionToolActive && that.lassoing) { + that.lasso_draw(event); + } + } + }) + + this.canvasOverlay = new OpenSeadragon.CanvasOverlayHd(this.viewer, { + onRedraw: function (opts) { + const context = opts.context; + //area selection polygon + if (that.selectionPolygonToDraw && that.selectionPolygonToDraw.length > 0) { + var d = that.selectionPolygonToDraw; + context.globalAlpha = 0.7; + context.strokeStyle = 'orange'; + context.lineWidth = 10; + context.beginPath(); + d.forEach(function (xVal, i) { + if (i === 0) { + context.moveTo(d[i].x, d[i].y); + } else { + context.lineTo(d[i].x, d[i].y); + } + }); + context.closePath(); + context.stroke(); + // context.globalAlpha = 1.0; + } + }, }); } @@ -408,6 +574,33 @@ class ImageViewer { await this.forceRepaint(); } + /** + * @function toggleLasso - Toggle on and off the lasso selection. + * @returns string + */ + toggleLasso(){ + if (this.lasso_toggle){ + this.lasso_toggle = false; + this.eventHandler.trigger(ImageViewer.events.clearImageLasso); + + d3.select('#selectionPolygon').style('stroke', 'none') + d3.select('#lasso-btn').style("color", "white") + d3.select('#lasso-toggle').style('visibility', 'hidden') + + } else { + this.lasso_toggle = true; + if(this.toggle_bool){ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); + } else{ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids_subtact}); + } + + d3.select('#selectionPolygon').style('stroke', 'orange') + d3.select('#lasso-btn').style("color", "orange") + d3.select('#lasso-toggle').style('visibility', 'visible') + } + } + /** * @function indexOfTexture - return integer for named texture * @param label - the texture key label @@ -1113,6 +1306,8 @@ ImageViewer.events = { imageClickedMultiSel: "image_clicked_multi_selection", renderingMode: "renderingMode", addScaleBar: "addScaleBar", + imageLassoSel: "image_lasso_selection", + clearImageLasso: "clear_image_lasso" }; /** diff --git a/minerva_analysis/client/templates/base.html b/minerva_analysis/client/templates/base.html index 32f24b390..939f0cf58 100644 --- a/minerva_analysis/client/templates/base.html +++ b/minerva_analysis/client/templates/base.html @@ -17,6 +17,14 @@ + + + + + + + + diff --git a/minerva_analysis/server/models/data_model.py b/minerva_analysis/server/models/data_model.py index 48c59e0cd..6b8a33b2a 100644 --- a/minerva_analysis/server/models/data_model.py +++ b/minerva_analysis/server/models/data_model.py @@ -1,4 +1,5 @@ from sklearn.neighbors import BallTree +from sklearn.preprocessing import MinMaxScaler import numpy as np import pandas as pd from PIL import ImageColor @@ -11,6 +12,9 @@ from minerva_analysis import config_json_path, data_path, cwd_path from minerva_analysis.server.utils import pyramid_assemble, pyramid_upgrade from minerva_analysis.server.models import database_model +from minerva_analysis.server.utils import smallestenclosingcircle +import matplotlib.path as mpltPath +from itertools import chain import dateutil.parser import time import pickle @@ -881,3 +885,34 @@ def logTransform(csvPath, skip_columns=[]): if column not in skip_columns: df[column] = np.log1p(df[column]) df.to_csv(csvPath, index=False) + +# similar_neighborhood=False, embedding=False +def get_cells_in_polygon(datasource_name, points): + global config + global datasource + global ball_tree + + if datasource_name != source: + load_datasource(datasource_name) + + point_tuples = [(e['imagePoints']['x'], e['imagePoints']['y']) for e in points] + (x, y, r) = smallestenclosingcircle.make_circle(point_tuples) + + index = ball_tree.query_radius([[x, y]], r) + neighbors = index[0] + circle_neighbors = datasource.iloc[neighbors].to_dict(orient='records') + neighbor_points = pd.DataFrame(circle_neighbors).values + + path = mpltPath.Path(point_tuples) + inside = path.contains_points(neighbor_points[:, [1, 2]].astype('float')) + neighbor_ids = neighbor_points[np.where(inside == True), 0].astype('int').flatten().tolist() + neighbor_ids.sort() + + neighbor_ids_subtract = list(set(datasource['CellID']) - set(neighbor_ids)) + neighbor_ids_subtract.sort() + + packet = {} + packet['list_ids'] = neighbor_ids + packet['list_ids_subtract'] = neighbor_ids_subtract + + return packet diff --git a/minerva_analysis/server/routes/data_routes.py b/minerva_analysis/server/routes/data_routes.py index 2bb6a352c..c0b3edbbc 100644 --- a/minerva_analysis/server/routes/data_routes.py +++ b/minerva_analysis/server/routes/data_routes.py @@ -335,3 +335,11 @@ def serialize_and_submit_json(data): mimetype='application/json' ) return response + +@app.route('/get_cells_in_polygon', methods=['POST']) +def get_cells_in_polygon(): + post_data = json.loads(request.data) + datasource = post_data['datasource'] + points = post_data['points'] + resp = data_model.get_cells_in_polygon(datasource, points) + return serialize_and_submit_json(resp) diff --git a/minerva_analysis/server/utils/smallestenclosingcircle.py b/minerva_analysis/server/utils/smallestenclosingcircle.py new file mode 100644 index 000000000..a27902d12 --- /dev/null +++ b/minerva_analysis/server/utils/smallestenclosingcircle.py @@ -0,0 +1,126 @@ +# +# Smallest enclosing circle - Library (Python) +# +# Copyright (c) 2020 Project Nayuki +# https://www.nayuki.io/page/smallest-enclosing-circle +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program (see COPYING.txt and COPYING.LESSER.txt). +# If not, see . +# + +import math, random + + +# Data conventions: A point is a pair of floats (x, y). A circle is a triple of floats (center x, center y, radius). + +# Returns the smallest circle that encloses all the given points. Runs in expected O(n) time, randomized. +# Input: A sequence of pairs of floats or ints, e.g. [(0,5), (3.1,-2.7)]. +# Output: A triple of floats representing a circle. +# Note: If 0 points are given, None is returned. If 1 point is given, a circle of radius 0 is returned. +# +# Initially: No boundary points known +def make_circle(points): + # Convert to float and randomize order + shuffled = [(float(x), float(y)) for (x, y) in points] + random.shuffle(shuffled) + + # Progressively add points to circle or recompute circle + c = None + for (i, p) in enumerate(shuffled): + if c is None or not is_in_circle(c, p): + c = _make_circle_one_point(shuffled[ : i + 1], p) + return c + + +# One boundary point known +def _make_circle_one_point(points, p): + c = (p[0], p[1], 0.0) + for (i, q) in enumerate(points): + if not is_in_circle(c, q): + if c[2] == 0.0: + c = make_diameter(p, q) + else: + c = _make_circle_two_points(points[ : i + 1], p, q) + return c + + +# Two boundary points known +def _make_circle_two_points(points, p, q): + circ = make_diameter(p, q) + left = None + right = None + px, py = p + qx, qy = q + + # For each point not in the two-point circle + for r in points: + if is_in_circle(circ, r): + continue + + # Form a circumcircle and classify it on left or right side + cross = _cross_product(px, py, qx, qy, r[0], r[1]) + c = make_circumcircle(p, q, r) + if c is None: + continue + elif cross > 0.0 and (left is None or _cross_product(px, py, qx, qy, c[0], c[1]) > _cross_product(px, py, qx, qy, left[0], left[1])): + left = c + elif cross < 0.0 and (right is None or _cross_product(px, py, qx, qy, c[0], c[1]) < _cross_product(px, py, qx, qy, right[0], right[1])): + right = c + + # Select which circle to return + if left is None and right is None: + return circ + elif left is None: + return right + elif right is None: + return left + else: + return left if (left[2] <= right[2]) else right + + +def make_diameter(a, b): + cx = (a[0] + b[0]) / 2 + cy = (a[1] + b[1]) / 2 + r0 = math.hypot(cx - a[0], cy - a[1]) + r1 = math.hypot(cx - b[0], cy - b[1]) + return (cx, cy, max(r0, r1)) + + +def make_circumcircle(a, b, c): + # Mathematical algorithm from Wikipedia: Circumscribed circle + ox = (min(a[0], b[0], c[0]) + max(a[0], b[0], c[0])) / 2 + oy = (min(a[1], b[1], c[1]) + max(a[1], b[1], c[1])) / 2 + ax = a[0] - ox; ay = a[1] - oy + bx = b[0] - ox; by = b[1] - oy + cx = c[0] - ox; cy = c[1] - oy + d = (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) * 2.0 + if d == 0.0: + return None + x = ox + ((ax*ax + ay*ay) * (by - cy) + (bx*bx + by*by) * (cy - ay) + (cx*cx + cy*cy) * (ay - by)) / d + y = oy + ((ax*ax + ay*ay) * (cx - bx) + (bx*bx + by*by) * (ax - cx) + (cx*cx + cy*cy) * (bx - ax)) / d + ra = math.hypot(x - a[0], y - a[1]) + rb = math.hypot(x - b[0], y - b[1]) + rc = math.hypot(x - c[0], y - c[1]) + return (x, y, max(ra, rb, rc)) + + +_MULTIPLICATIVE_EPSILON = 1 + 1e-14 + +def is_in_circle(c, p): + return c is not None and math.hypot(p[0] - c[0], p[1] - c[1]) <= c[2] * _MULTIPLICATIVE_EPSILON + + +# Returns twice the signed area of the triangle defined by (x0, y0), (x1, y1), (x2, y2). +def _cross_product(x0, y0, x1, y1, x2, y2): + return (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0) diff --git a/requirements.yml b/requirements.yml index acb28eed6..65c0371d5 100644 --- a/requirements.yml +++ b/requirements.yml @@ -7,6 +7,7 @@ dependencies: - flask-sqlalchemy=3.0.2 - itsdangerous=2.1.2 - jinja2 + - matplotlib - numpy - ome-types - orjson From 3889f214fd063d6bd939fa681f337cd231d7f865 Mon Sep 17 00:00:00 2001 From: Nhan Huynh Date: Sat, 20 May 2023 16:42:22 -0400 Subject: [PATCH 2/7] updated gating download to include spatial selection --- .../client/src/js/services/dataLayer.js | 10 +++++-- .../client/src/js/views/csvGatingList.js | 2 +- .../client/src/js/views/imageViewer.js | 4 +++ minerva_analysis/server/models/data_model.py | 29 +++++++++++-------- minerva_analysis/server/routes/data_routes.py | 3 +- 5 files changed, 32 insertions(+), 16 deletions(-) diff --git a/minerva_analysis/client/src/js/services/dataLayer.js b/minerva_analysis/client/src/js/services/dataLayer.js index b0f52bebe..acec8a2eb 100644 --- a/minerva_analysis/client/src/js/services/dataLayer.js +++ b/minerva_analysis/client/src/js/services/dataLayer.js @@ -90,7 +90,7 @@ class DataLayer { } } - downloadGatingCSV(channels, selections, fullCsv = false) { + downloadGatingCSV(channels, selections, selection_ids, fullCsv = false) { let form = document.createElement("form"); form.action = "/download_gating_csv"; @@ -133,14 +133,20 @@ class DataLayer { channelsElement.name = "channels"; form.appendChild(channelsElement); + let idsElement = document.createElement("input"); + idsElement.type = "hidden"; + idsElement.value = JSON.stringify(selection_ids); + idsElement.name = "selection_ids"; + form.appendChild(idsElement); + let datasourceElement = document.createElement("input"); datasourceElement.type = "hidden"; datasourceElement.value = datasource; datasourceElement.name = "datasource"; form.appendChild(datasourceElement); + document.body.appendChild(form); form.submit() - } async saveGatingList(channels, selections) { diff --git a/minerva_analysis/client/src/js/views/csvGatingList.js b/minerva_analysis/client/src/js/views/csvGatingList.js index 2533177ec..3551f630f 100644 --- a/minerva_analysis/client/src/js/views/csvGatingList.js +++ b/minerva_analysis/client/src/js/views/csvGatingList.js @@ -450,7 +450,7 @@ class CSVGatingList { // Download gated channel ranges download_gated_cell_encodings.addEventListener('click', () => { - this.dataLayer.downloadGatingCSV(this.gating_channels, this.selections, true); + this.dataLayer.downloadGatingCSV(this.gating_channels, this.selections, this.seaDragonViewer.selection_ids, true); }) // Toggle outlined / filled cell selections diff --git a/minerva_analysis/client/src/js/views/imageViewer.js b/minerva_analysis/client/src/js/views/imageViewer.js index 22d09ebf8..28152f880 100644 --- a/minerva_analysis/client/src/js/views/imageViewer.js +++ b/minerva_analysis/client/src/js/views/imageViewer.js @@ -36,6 +36,7 @@ class ImageViewer { this.lasso_toggle = false; this.lasso_ids = {}; this.lasso_ids_subtact = {}; + this.selection_ids = {}; this.lasso_init = false; this.toggle_bool = true; @@ -455,6 +456,7 @@ class ImageViewer { return dataLayer.getCellsInPolygon(that.polygonSelection) .then(packet =>{ this.lasso_toggle = true; + this.selection_ids = packet['list_ids']; this.lasso_ids = packet['list_ids']; this.lasso_ids_subtact = packet['list_ids_subtract']; this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); @@ -474,9 +476,11 @@ class ImageViewer { toggle_lasso.addEventListener("click", (e) => { if(this.toggle_bool){ this.toggle_bool = false; + this.selection_ids = this.lasso_ids_subtact; this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids_subtact}); } else{ this.toggle_bool = true; + this.selection_ids = this.lasso_ids; this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); } }); diff --git a/minerva_analysis/server/models/data_model.py b/minerva_analysis/server/models/data_model.py index 6b8a33b2a..b25624c7a 100644 --- a/minerva_analysis/server/models/data_model.py +++ b/minerva_analysis/server/models/data_model.py @@ -472,7 +472,7 @@ def get_all_cells(datasource_name, start_keys, data_type=float): return query.astype(np.float32) -def download_gating_csv(datasource_name, gates, channels, encoding): +def download_gating_csv(datasource_name, gates, channels, selection_ids, encoding): global datasource global source global ball_tree @@ -481,28 +481,33 @@ def download_gating_csv(datasource_name, gates, channels, encoding): if datasource_name != source: load_ball_tree(datasource_name) - query_string = '' + csv = datasource.copy() + columns = [] - for key, value in gates.items(): - columns.append(key) - if query_string != '': - query_string += ' and ' - query_string += str(value[0]) + ' < `' + key + '` < ' + str(value[1]) - ids = datasource.query(query_string)[['id']].to_numpy().flatten() if 'idField' in config[datasource_name]['featureData'][0]: idField = config[datasource_name]['featureData'][0]['idField'] else: idField = "CellID" columns.append(idField) - csv = datasource.copy() + if selection_ids: + datasource = datasource[datasource[idField].isin(selection_ids)] + + query_string = '' + for key, value in gates.items(): + columns.append(key) + if query_string != '': + query_string += ' and ' + query_string += str(value[0]) + ' < `' + key + '` < ' + str(value[1]) + ids = datasource.query(query_string)[['id']].to_numpy().flatten() - csv[idField] = datasource['id'] + if 'Area' in channels: + del channels['Area'] for channel in channels: if channel in gates: if encoding == 'binary': - csv.loc[csv[idField].isin(ids), channel] = 1 - csv.loc[~csv[idField].isin(ids), channel] = 0 + csv.loc[csv.index.isin(ids), channel] = 1 + csv.loc[~csv.index.isin(ids), channel] = 0 else: csv[channel] = 0 diff --git a/minerva_analysis/server/routes/data_routes.py b/minerva_analysis/server/routes/data_routes.py index c0b3edbbc..dc34dd1e2 100644 --- a/minerva_analysis/server/routes/data_routes.py +++ b/minerva_analysis/server/routes/data_routes.py @@ -222,10 +222,11 @@ def download_gating_csv(): filter = json.loads(request.form['filter']) channels = json.loads(request.form['channels']) + selection_ids = json.loads(request.form['selection_ids']) fullCsv = json.loads(request.form['fullCsv']) encoding = request.form['encoding'] if fullCsv: - csv = data_model.download_gating_csv(datasource, filter, channels, encoding) + csv = data_model.download_gating_csv(datasource, filter, channels, selection_ids, encoding) return Response( csv.to_csv(index=False), mimetype="text/csv", From 0b88bff27513e587369ac77370630a7656611249 Mon Sep 17 00:00:00 2001 From: Nhan Huynh Date: Sat, 20 May 2023 18:10:09 -0400 Subject: [PATCH 3/7] added check before drawing gating gmm --- minerva_analysis/client/src/js/views/csvGatingList.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/minerva_analysis/client/src/js/views/csvGatingList.js b/minerva_analysis/client/src/js/views/csvGatingList.js index 3551f630f..fcec46538 100644 --- a/minerva_analysis/client/src/js/views/csvGatingList.js +++ b/minerva_analysis/client/src/js/views/csvGatingList.js @@ -47,9 +47,11 @@ class CSVGatingList { this.selections[fullName] = values; this.sliders.get(name).value(values); this.eventHandler.trigger(CSVGatingList.events.GATING_BRUSH_MOVE, this.selections); - this.getAndDrawGatingGMM(name).then(() => { - this.eventHandler.trigger(CSVGatingList.events.GATING_BRUSH_END, this.selections); - }); + if (!(name in this.hasGatingGMM)) { + this.getAndDrawGatingGMM(name).then(() => { + this.eventHandler.trigger(CSVGatingList.events.GATING_BRUSH_END, this.selections); + }); + } } /** From 15d3bb0c9e8acddb4fb8ed1d77d3250dfed4ccbe Mon Sep 17 00:00:00 2001 From: Nhan Huynh Date: Tue, 13 Jun 2023 13:36:25 -0400 Subject: [PATCH 4/7] Added update for GMM traces after spatial selection --- minerva_analysis/client/src/js/main.js | 2 + .../client/src/js/services/dataLayer.js | 20 +++-- .../client/src/js/views/csvGatingList.js | 77 ++++++++++++++----- .../client/src/js/views/imageViewer.js | 2 +- minerva_analysis/server/models/data_model.py | 18 ++++- minerva_analysis/server/routes/data_routes.py | 12 +-- 6 files changed, 95 insertions(+), 36 deletions(-) diff --git a/minerva_analysis/client/src/js/main.js b/minerva_analysis/client/src/js/main.js index f65b47e7f..f7555e907 100644 --- a/minerva_analysis/client/src/js/main.js +++ b/minerva_analysis/client/src/js/main.js @@ -171,6 +171,7 @@ async function init(config) { */ const imageLassoSel = (d) => { updateSeaDragonSelection(d); + csv_gatingList.updateGMM(d['picked']); }; eventHandler.bind(ImageViewer.events.imageLassoSel, imageLassoSel); @@ -179,6 +180,7 @@ async function init(config) { */ function clearSeaDragonSelection() { updateSeaDragonSelection({ picked: [] }); + csv_gatingList.updateGMM([]); } eventHandler.bind(ImageViewer.events.clearImageLasso, clearSeaDragonSelection); diff --git a/minerva_analysis/client/src/js/services/dataLayer.js b/minerva_analysis/client/src/js/services/dataLayer.js index acec8a2eb..022380901 100644 --- a/minerva_analysis/client/src/js/services/dataLayer.js +++ b/minerva_analysis/client/src/js/services/dataLayer.js @@ -369,12 +369,22 @@ class DataLayer { } } - async getGatingGMM(channel) { + async getGatingGMM(channel, selection_ids) { try { - let response = await fetch('/get_gating_gmm?' + new URLSearchParams({ - channel: channel, - datasource: datasource - })) + let response = await fetch('/get_gating_gmm', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify( + { + channel: channel, + datasource: datasource, + selection_ids: selection_ids + } + ) + }); let packet_gmm = await response.json(); return packet_gmm; } catch (e) { diff --git a/minerva_analysis/client/src/js/views/csvGatingList.js b/minerva_analysis/client/src/js/views/csvGatingList.js index fcec46538..0d067e3d1 100644 --- a/minerva_analysis/client/src/js/views/csvGatingList.js +++ b/minerva_analysis/client/src/js/views/csvGatingList.js @@ -302,10 +302,10 @@ class CSVGatingList { * @param name - the name of the channel to apply it to */ async autoGate(shortName) { + const fullName = this.dataLayer.getFullChannelName(shortName); + const input = (await this.getGatingGMM(fullName, this.seaDragonViewer.selection_ids)).gate.toFixed(7); const transformed = this.dataLayer.isTransformed(); - const input = (await this.getGatingGMM(shortName)).gate.toFixed(7); const gate = transformed ? parseFloat(input) : parseInt(input); - const fullName = this.dataLayer.getFullChannelName(shortName); if (fullName in this.selections) { const gate_end = this.selections[fullName][1]; const slider = this.sliders.get(shortName); @@ -531,7 +531,7 @@ class CSVGatingList { if (!data) return; const fullName = this.dataLayer.getFullChannelName(name); - const { xDomain, yDomain, histogramData} = this.histogramData(fullName); + const { xDomain, yDomain, histogramData } = this.histogramData(fullName); let channelID = this.gatingIDs[name]; let data_min @@ -674,29 +674,17 @@ class CSVGatingList { }; } - async getGatingGMM(name) { - let packet = this.hasGatingGMM[name]; - if (!(name in this.hasGatingGMM)) { - const fullName = this.dataLayer.getFullChannelName(name); - packet = await this.dataLayer.getGatingGMM(fullName); - this.hasGatingGMM[name] = packet; - } - const channelID = this.gatingIDs[name]; - const autoBtn = document.getElementById(`auto-btn-gating_${channelID}`); - autoBtn.classList.remove("auto-loading") - return packet; - } - - async getAndDrawGatingGMM(name) { + async getGatingGMM(name, selection_ids = []) { const fullName = this.dataLayer.getFullChannelName(name); - await this.getGatingGMM(name); - this.drawGatingGMM(name); + let packet = await this.dataLayer.getGatingGMM(fullName, selection_ids); + this.hasGatingGMM[name] = packet; + return packet; } drawGatingGMM(name) { let channelID = this.gatingIDs[name]; const fullName = this.dataLayer.getFullChannelName(name); - const { xDomain, yDomain } = this.histogramData(fullName); + const { xDomain, yDomain } = this.histogramData(fullName); const packet = this.hasGatingGMM[name]; let gmm1Data = packet['gmm_1']; let gmm2Data = packet['gmm_2']; @@ -726,6 +714,7 @@ class CSVGatingList { .attr('d', line) .attr('class', 'gmm_line') .attr('class', 'gmm_line_'+name) + .attr('id', 'gmm1_line_'+name) .attr('transform', 'translate(0,-31)') .attr('fill', 'none') .attr('stroke', 'blue') @@ -737,11 +726,59 @@ class CSVGatingList { .attr('d', line) .attr('class', 'gmm_line') .attr('class', 'gmm_line_'+name) + .attr('id', 'gmm2_line_'+name) .attr('transform', 'translate(0,-31)') .attr('fill', 'none') .attr('stroke', 'red') } + async getAndDrawGatingGMM(name) { + await this.getGatingGMM(name); + + const channelID = this.gatingIDs[name]; + const autoBtn = document.getElementById(`auto-btn-gating_${channelID}`); + autoBtn.classList.remove("auto-loading") + + this.drawGatingGMM(name); + } + + async updateGMM(selection_ids) { + for (let name in this.hasGatingGMM) { + await this.getGatingGMM(name, selection_ids=selection_ids); + + const fullName = this.dataLayer.getFullChannelName(name); + const { xDomain, yDomain } = this.histogramData(fullName); + const packet = this.hasGatingGMM[name]; + let gmm1Data = packet['gmm_1']; + let gmm2Data = packet['gmm_2']; + const gmm1_yMax = Math.max(...gmm1Data.map(obj => obj.y)); + const gmm2_yMax = Math.max(...gmm2Data.map(obj => obj.y)); + const yMax = Math.max(gmm1_yMax, gmm2_yMax); + const gmm_yDomain = [yMax, 0] + + const gatingListEl = document.getElementById("csv_gating_list"); + const swidth = gatingListEl.getBoundingClientRect().width; + + let xScale = d3.scaleLinear() + .domain(xDomain) + .range([0, swidth - 73]) + + let yScale = d3.scaleLinear() + .domain(gmm_yDomain) + .range([0, 25]) + + let line = d3.line() + .x(d => xScale(d.x)) + .y(d => yScale(d.y)) + .curve(d3.curveMonotoneX) + + let channel_gmm1 = d3.select('#gmm1_line_'+name) + let channel_gmm2 = d3.select('#gmm2_line_'+name) + channel_gmm1.data([gmm1Data]).transition().duration(1000).attr('d', line) + channel_gmm2.data([gmm2Data]).transition().duration(1000).attr('d', line) + } + } + /** * @function resetGatingList - resets all channels in the list to its initial range */ diff --git a/minerva_analysis/client/src/js/views/imageViewer.js b/minerva_analysis/client/src/js/views/imageViewer.js index 28152f880..1d51975d9 100644 --- a/minerva_analysis/client/src/js/views/imageViewer.js +++ b/minerva_analysis/client/src/js/views/imageViewer.js @@ -36,7 +36,7 @@ class ImageViewer { this.lasso_toggle = false; this.lasso_ids = {}; this.lasso_ids_subtact = {}; - this.selection_ids = {}; + this.selection_ids = []; this.lasso_init = false; this.toggle_bool = true; diff --git a/minerva_analysis/server/models/data_model.py b/minerva_analysis/server/models/data_model.py index b25624c7a..8523e85c6 100644 --- a/minerva_analysis/server/models/data_model.py +++ b/minerva_analysis/server/models/data_model.py @@ -482,6 +482,7 @@ def download_gating_csv(datasource_name, gates, channels, selection_ids, encodin load_ball_tree(datasource_name) csv = datasource.copy() + datasource_filter = datasource.copy() columns = [] if 'idField' in config[datasource_name]['featureData'][0]: @@ -491,7 +492,7 @@ def download_gating_csv(datasource_name, gates, channels, selection_ids, encodin columns.append(idField) if selection_ids: - datasource = datasource[datasource[idField].isin(selection_ids)] + datasource_filter = datasource_filter[datasource_filter[idField].isin(selection_ids)] query_string = '' for key, value in gates.items(): @@ -499,7 +500,7 @@ def download_gating_csv(datasource_name, gates, channels, selection_ids, encodin if query_string != '': query_string += ' and ' query_string += str(value[0]) + ' < `' + key + '` < ' + str(value[1]) - ids = datasource.query(query_string)[['id']].to_numpy().flatten() + ids = datasource_filter.query(query_string)[['id']].to_numpy().flatten() if 'Area' in channels: del channels['Area'] @@ -749,7 +750,7 @@ def get_channel_gmm(channel_name, datasource_name): return packet_gmm -def get_gating_gmm(channel_name, datasource_name): +def get_gating_gmm(channel_name, datasource_name, selection_ids): global datasource global source global ball_tree @@ -762,12 +763,21 @@ def get_gating_gmm(channel_name, datasource_name): load_ball_tree(datasource_name) description = datasource.describe().to_dict() + datasource_filter = datasource.copy() + if 'idField' in config[datasource_name]['featureData'][0]: + idField = config[datasource_name]['featureData'][0]['idField'] + else: + idField = "CellID" + if selection_ids: + datasource_filter = datasource_filter[datasource_filter[idField].isin(selection_ids)] + column_data = datasource[channel_name].to_numpy() [hist, bin_edges] = np.histogram(column_data[~np.isnan(column_data)], bins=50, density=True) midpoints = (bin_edges[1:] + bin_edges[:-1]) / 2 + column_data_filtered = datasource_filter[channel_name].to_numpy() gmm = GaussianMixture(n_components=2) - gmm.fit(column_data.reshape((-1, 1))) + gmm.fit(column_data_filtered.reshape((-1, 1))) i0, i1 = np.argsort(gmm.means_[:, 0]) packet_gmm['gate'] = np.mean(gmm.means_) diff --git a/minerva_analysis/server/routes/data_routes.py b/minerva_analysis/server/routes/data_routes.py index dc34dd1e2..3a8cc59c0 100644 --- a/minerva_analysis/server/routes/data_routes.py +++ b/minerva_analysis/server/routes/data_routes.py @@ -149,15 +149,15 @@ def get_channel_gmm(): resp = data_model.get_channel_gmm(channel, datasource) return serialize_and_submit_json(resp) - -@app.route('/get_gating_gmm', methods=['GET']) +@app.route('/get_gating_gmm', methods=['POST']) def get_gating_gmm(): - channel = request.args.get('channel') - datasource = request.args.get('datasource') - resp = data_model.get_gating_gmm(channel, datasource) + post_data = json.loads(request.data) + channel = post_data['channel'] + datasource = post_data['datasource'] + selection_ids = post_data['selection_ids'] + resp = data_model.get_gating_gmm(channel, datasource, selection_ids) return serialize_and_submit_json(resp) - @app.route('/upload_gates', methods=['POST']) def upload_gates(): file = request.files['file'] From c5055a4354c87b881ab8f4e306114ea971484efa Mon Sep 17 00:00:00 2001 From: Nhan Huynh Date: Tue, 3 Oct 2023 17:15:33 -0400 Subject: [PATCH 5/7] Added multiple selections and toggle for additive/subtractive selections --- minerva_analysis/client/src/css/main.css | 12 +- .../client/src/js/services/dataLayer.js | 35 ++- .../client/src/js/views/csvGatingList.js | 71 +++--- .../client/src/js/views/imageViewer.js | 219 +++++++++++++----- minerva_analysis/client/templates/index.html | 3 + minerva_analysis/server/models/data_model.py | 45 +++- minerva_analysis/server/routes/data_routes.py | 14 +- requirements.yml | 6 +- 8 files changed, 297 insertions(+), 108 deletions(-) diff --git a/minerva_analysis/client/src/css/main.css b/minerva_analysis/client/src/css/main.css index abe0dcc42..69a176a22 100644 --- a/minerva_analysis/client/src/css/main.css +++ b/minerva_analysis/client/src/css/main.css @@ -465,14 +465,22 @@ label { .col-svg-wrapper { } -#selectionPolygon { +.lasso_selection_toggle { + float: right; + margin-left: auto; + margin-top: 3px; + margin-right: 20px; + color: orange; +} + +.lasso_polygon { fill-opacity: 0; stroke: orange; stroke-width: 3px; vector-effect: non-scaling-stroke; } -#lasso-toggle { +.btn_lasso_delete { float: right; padding-top: 0; padding-bottom: 0; diff --git a/minerva_analysis/client/src/js/services/dataLayer.js b/minerva_analysis/client/src/js/services/dataLayer.js index 022380901..4342130a6 100644 --- a/minerva_analysis/client/src/js/services/dataLayer.js +++ b/minerva_analysis/client/src/js/services/dataLayer.js @@ -90,7 +90,7 @@ class DataLayer { } } - downloadGatingCSV(channels, selections, selection_ids, fullCsv = false) { + downloadGatingCSV(channels, selections, lassos, selection_ids, fullCsv = false) { let form = document.createElement("form"); form.action = "/download_gating_csv"; @@ -133,6 +133,12 @@ class DataLayer { channelsElement.name = "channels"; form.appendChild(channelsElement); + let lassosElement = document.createElement("input"); + lassosElement.type = "hidden"; + lassosElement.value = JSON.stringify(lassos); + lassosElement.name = "lassos"; + form.appendChild(lassosElement); + let idsElement = document.createElement("input"); idsElement.type = "hidden"; idsElement.value = JSON.stringify(selection_ids); @@ -149,7 +155,7 @@ class DataLayer { form.submit() } - async saveGatingList(channels, selections) { + async saveGatingList(channels, selections, lassos) { const self = this; try { let response = await fetch('/save_gating_list', { @@ -162,7 +168,8 @@ class DataLayer { { datasource: datasource, filter: selections, - channels: channels + channels: channels, + lassos: lassos } ) }); @@ -595,4 +602,26 @@ class DataLayer { } } + async getCellsInLassos(list_lassos) { + try { + let response = await fetch('/get_cells_in_lassos', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify( + { + datasource: datasource, + list_lassos: list_lassos, + } + ) + }); + let cells = await response.json(); + return cells; + } catch (e) { + console.log("Error Getting Cells in Lassos", e); + } + } + } diff --git a/minerva_analysis/client/src/js/views/csvGatingList.js b/minerva_analysis/client/src/js/views/csvGatingList.js index 0d067e3d1..2365c4186 100644 --- a/minerva_analysis/client/src/js/views/csvGatingList.js +++ b/minerva_analysis/client/src/js/views/csvGatingList.js @@ -261,40 +261,55 @@ class CSVGatingList { } this.eventHandler.trigger(CSVGatingList.events.RESET_GATINGLIST) - - _.each(gates, col => { - let shortName = this.dataLayer.getShortChannelName(col.channel); - let channelID = this.gatingIDs[shortName]; - if (this.sliders.get(shortName)) { - let toggle_off - if (!col.gate_active && col.channel in this.selections) { - toggle_off = true; - } else { - toggle_off = false; - } - this.gating_channels[col.channel] = [col.gate_start, col.gate_end]; - if (col.gate_active) { - // IF the channel isn't active, make it so - if (!this.selections[col.channel]) { - let selector = `#csv_gating-slider_${channelID}`; - document.querySelector(selector).click(); + let list_uploaded_lassos = []; + _.each(gates, async (col) => { + if (col.channel == 'Lasso') { + list_uploaded_lassos.push(col); + } else { + let shortName = this.dataLayer.getShortChannelName(col.channel); + let channelID = this.gatingIDs[shortName]; + if (this.sliders.get(shortName)) { + let toggle_off + if (!col.gate_active && col.channel in this.selections) { + toggle_off = true; + } else { + toggle_off = false; } - this.selections[col.channel] = [col.gate_start, col.gate_end]; + this.gating_channels[col.channel] = [col.gate_start, col.gate_end]; + if (col.gate_active) { + // IF the channel isn't active, make it so + if (!this.selections[col.channel]) { + let selector = `#csv_gating-slider_${channelID}`; + document.querySelector(selector).click(); + } + this.selections[col.channel] = [col.gate_start, col.gate_end]; - // For records + // For records - } else { - // If channel is currently active, but shouldn't be, update it - if (toggle_off) { - let selector = `#csv_gating-slider_${channelID}`; - document.querySelector(selector).click(); + } else { + // If channel is currently active, but shouldn't be, update it + if (toggle_off) { + let selector = `#csv_gating-slider_${channelID}`; + document.querySelector(selector).click(); + } + delete this.selections[col.channel]; } - delete this.selections[col.channel]; } } }) // Trigger brush this.eventHandler.trigger(CSVGatingList.events.GATING_BRUSH_END, this.selections); + + await this.seaDragonViewer.clear_lassos(); + if (source === 'file'){ + list_uploaded_lassos = list_uploaded_lassos.map(item => { + item['gate_start'] = JSON.parse(item['gate_start'].replace(/'/g, '"')); + return item; + }); + } + for (let lasso of list_uploaded_lassos){ + await this.seaDragonViewer.upload_lasso(lasso); + } } /** @@ -421,7 +436,7 @@ class CSVGatingList { // Events :: gating_download_icon_db.addEventListener('click', () => { - this.dataLayer.saveGatingList(this.gating_channels, this.selections, false); + this.dataLayer.saveGatingList(this.gating_channels, this.selections, this.seaDragonViewer.list_lassos); alert("Saved Gating to Database"); }) @@ -447,12 +462,12 @@ class CSVGatingList { // Download gated channel ranges download_gated_channel_ranges.addEventListener('click', () => { - this.dataLayer.downloadGatingCSV(this.gating_channels, this.selections, false); + this.dataLayer.downloadGatingCSV(this.gating_channels, this.selections, this.seaDragonViewer.list_lassos,false); }) // Download gated channel ranges download_gated_cell_encodings.addEventListener('click', () => { - this.dataLayer.downloadGatingCSV(this.gating_channels, this.selections, this.seaDragonViewer.selection_ids, true); + this.dataLayer.downloadGatingCSV(this.gating_channels, this.selections, this.seaDragonViewer.list_lassos, this.seaDragonViewer.selection_ids, true); }) // Toggle outlined / filled cell selections diff --git a/minerva_analysis/client/src/js/views/imageViewer.js b/minerva_analysis/client/src/js/views/imageViewer.js index 1d51975d9..71d3ff58c 100644 --- a/minerva_analysis/client/src/js/views/imageViewer.js +++ b/minerva_analysis/client/src/js/views/imageViewer.js @@ -33,11 +33,13 @@ class ImageViewer { this._cacheKeys = {}; this._picking = []; this.pickedId = -1; - this.lasso_toggle = false; - this.lasso_ids = {}; - this.lasso_ids_subtact = {}; - this.selection_ids = []; - this.lasso_init = false; + + this.list_lassos = {}; + this.count_lassos = 0; + this.lasso_selections = { + lasso_ids : [], + lasso_ids_subtract : [] + }; this.toggle_bool = true; // Viewer @@ -387,7 +389,7 @@ class ImageViewer { } }); - //SELECTION POLYGON (LASSO) + //LASSO SELECTION POLYGON let that = this; that.svg_overlay = that.viewer.svgOverlay() that.overlay = d3.select(that.svg_overlay.node()) @@ -399,6 +401,7 @@ class ImageViewer { that.isSelectionToolActive = true; that.lasso_draw = function (event) { + let id_polygon = "polygon_" + this.count_lassos; //add points to polygon and (re)draw let webPoint = event.position; if (that.numCalls % 5 == 0) { @@ -411,10 +414,12 @@ class ImageViewer { that.polygonSelection.push({'imagePoints': imagePoint, 'viewportPoints': viewportPoint}); } - d3.select('#selectionPolygon').remove(); - var selPoly = that.overlay.selectAll("selectionPolygon").data([that.polygonSelection]); + d3.select('#'+id_polygon).remove(); + + let selPoly = that.overlay.selectAll(id_polygon).data([that.polygonSelection]); selPoly.enter().append("polygon") - .attr('id', 'selectionPolygon') + .attr('id', id_polygon) + .attr('class', 'lasso_polygon') .attr("points", function (d) { return d.map(function (d) { return [d.viewportPoints.x, d.viewportPoints.y].join(","); @@ -439,7 +444,6 @@ class ImageViewer { } if (!that.isSelectionToolActive) { d3.select('#selectionPolygon').remove(); - } that.polygonSelection = []; that.numCalls = 0; @@ -452,42 +456,7 @@ class ImageViewer { if (that.isSelectionToolActive) { that.lasso_end(event); if (_.size(that.polygonSelection) > 2) { - // that.showLoader(); - return dataLayer.getCellsInPolygon(that.polygonSelection) - .then(packet =>{ - this.lasso_toggle = true; - this.selection_ids = packet['list_ids']; - this.lasso_ids = packet['list_ids']; - this.lasso_ids_subtact = packet['list_ids_subtract']; - this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); - if(!this.lasso_init){ - $('#gating_list_ul').prepend("
" + - "Lasso Selection" + - "" + - "
" - ); - document.getElementById('lasso-btn').addEventListener("click", e => { - return this.toggleLasso(); - }) - const toggle_lasso = document.querySelector("#lasso-toggle"); - this.toggle_bool = true; - toggle_lasso.addEventListener("click", (e) => { - if(this.toggle_bool){ - this.toggle_bool = false; - this.selection_ids = this.lasso_ids_subtact; - this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids_subtact}); - } else{ - this.toggle_bool = true; - this.selection_ids = this.lasso_ids; - this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); - } - }); - toggle_lasso.addEventListener("click", e => e.stopPropagation()); - this.lasso_init = true; - } - }) + return that.draw_lasso(that.polygonSelection); } } } @@ -576,32 +545,156 @@ class ImageViewer { this.idCount = ids.length; this.ready = true; await this.forceRepaint(); + + const toggle_lasso_plus = document.querySelector("#lasso_selection_toggle_plus"); + toggle_lasso_plus.addEventListener("click", (e) => { + this.toggle_bool = false; + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids_subtract}); + + document.getElementById("lasso_selection_toggle_plus").style.display = "none"; + document.getElementById("lasso_selection_toggle_minus").style.display = ""; + }); + toggle_lasso_plus.addEventListener("click", e => e.stopPropagation()); + + const toggle_lasso_minus = document.querySelector("#lasso_selection_toggle_minus"); + toggle_lasso_minus.addEventListener("click", (e) => { + this.toggle_bool = true; + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids}); + + document.getElementById("lasso_selection_toggle_minus").style.display = "none"; + document.getElementById("lasso_selection_toggle_plus").style.display = ""; + }); + toggle_lasso_minus.addEventListener("click", e => e.stopPropagation()); + } + + async draw_lasso(polygonSelection){ + let id_polygon = "polygon_" + this.count_lassos; + let id_polygon_selection = id_polygon + "_selection" + let id_polygon_delete = id_polygon + "_delete" + this.count_lassos++; + + this.lasso_ids = await this.dataLayer.getCellsInPolygon(polygonSelection) + this.list_lassos[id_polygon] = { + lasso_polygon: polygonSelection, + lasso_ids : this.lasso_ids, + lasso_toggle : true, + } + + this.cells_in_lassos = await this.dataLayer.getCellsInLassos(this.list_lassos); + this.lasso_selections.lasso_ids = this.cells_in_lassos['lasso_ids'] + this.lasso_selections.lasso_ids_subtract = this.cells_in_lassos['lasso_ids_subtract'] + + if(this.toggle_bool){ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids}); + } else{ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids_subtract}); + } + + $('#gating_list_ul').prepend("
" + + "Lasso Selection" + + "" + + "
" + ); + + let btn_lasso_delete = document.getElementById(id_polygon_delete) + btn_lasso_delete.addEventListener("click", async () => { + return this.delete_lasso(id_polygon) + }); + btn_lasso_delete.addEventListener("click", e => e.stopPropagation()); + + document.getElementById(id_polygon_selection).addEventListener("mouseover", e => { + if (this.list_lassos[id_polygon].lasso_toggle === true) { + $('#'+id_polygon).css('stroke-width', '6px'); + } else { + $('#'+id_polygon).css('stroke-width', '3px'); + $('#'+id_polygon).css('stroke', 'orange'); + } + }) + + document.getElementById(id_polygon_selection).addEventListener("mouseout", e => { + if (this.list_lassos[id_polygon].lasso_toggle === true) { + $('#'+id_polygon).css('stroke-width', '3px'); + } else { + $('#'+id_polygon).css('stroke', 'none'); + } + }) + + document.getElementById(id_polygon_selection).addEventListener("click", async () => { + return this.toggle_lasso(id_polygon, id_polygon_selection); + }) + } + + async clear_lassos(){ + for (let polygon in this.list_lassos) { + await this.delete_lasso(polygon); + } + this.count_lassos = 0; + } + + async upload_lasso(selection){ + let polygonSelection = selection['gate_start'] + let id_polygon = 'polygon_' + this.count_lassos + let selPoly = this.overlay.selectAll(id_polygon).data([polygonSelection]); + selPoly.enter().append("polygon") + .attr('id', id_polygon) + .attr('class', 'lasso_polygon') + .attr("points", function (d) { + return d.map(function (d) { + return [d.viewportPoints.x, d.viewportPoints.y].join(","); + }).join(" "); + }) + + await this.draw_lasso(polygonSelection) + if (!selection['gate_active']){ + this.toggle_lasso(id_polygon, id_polygon+"_selection"); + } } /** - * @function toggleLasso - Toggle on and off the lasso selection. + * @function toggle_lasso - Toggle on and off the lasso selection. * @returns string */ - toggleLasso(){ - if (this.lasso_toggle){ - this.lasso_toggle = false; - this.eventHandler.trigger(ImageViewer.events.clearImageLasso); + async toggle_lasso(id_polygon, id_polygon_selection){ + if (this.list_lassos[id_polygon].lasso_toggle){ + this.list_lassos[id_polygon].lasso_toggle = false; - d3.select('#selectionPolygon').style('stroke', 'none') - d3.select('#lasso-btn').style("color", "white") - d3.select('#lasso-toggle').style('visibility', 'hidden') + d3.select('#'+id_polygon).style('stroke', 'none') + d3.select('#'+id_polygon_selection).style("color", "white") } else { - this.lasso_toggle = true; - if(this.toggle_bool){ - this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids}); - } else{ - this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_ids_subtact}); - } + this.list_lassos[id_polygon].lasso_toggle = true; + + d3.select('#'+id_polygon).style('stroke', 'orange') + d3.select('#'+id_polygon_selection).style("color", "orange") + } + + this.cells_in_lassos = await this.dataLayer.getCellsInLassos(this.list_lassos); + this.lasso_selections.lasso_ids = this.cells_in_lassos['lasso_ids'] + this.lasso_selections.lasso_ids_subtract = this.cells_in_lassos['lasso_ids_subtract'] + + if(this.toggle_bool){ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids}); + } else{ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids_subtract}); + } + } + + async delete_lasso(id_polygon) { + let id_polygon_selection = id_polygon + "_selection" + d3.select('#'+id_polygon).remove(); + delete this.list_lassos[id_polygon]; + d3.select('#'+id_polygon_selection).remove(); + + this.cells_in_lassos = await this.dataLayer.getCellsInLassos(this.list_lassos); + this.lasso_selections.lasso_ids = this.cells_in_lassos['lasso_ids'] + this.lasso_selections.lasso_ids_subtract = this.cells_in_lassos['lasso_ids_subtract'] - d3.select('#selectionPolygon').style('stroke', 'orange') - d3.select('#lasso-btn').style("color", "orange") - d3.select('#lasso-toggle').style('visibility', 'visible') + if(this.toggle_bool){ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids}); + } else{ + this.eventHandler.trigger(ImageViewer.events.imageLassoSel, {'picked': this.lasso_selections.lasso_ids_subtract}); } } diff --git a/minerva_analysis/client/templates/index.html b/minerva_analysis/client/templates/index.html index 343cac3d7..4aaf4fd95 100644 --- a/minerva_analysis/client/templates/index.html +++ b/minerva_analysis/client/templates/index.html @@ -94,6 +94,9 @@ + + +
diff --git a/minerva_analysis/server/models/data_model.py b/minerva_analysis/server/models/data_model.py index 8523e85c6..7cf6be4a4 100644 --- a/minerva_analysis/server/models/data_model.py +++ b/minerva_analysis/server/models/data_model.py @@ -515,7 +515,7 @@ def download_gating_csv(datasource_name, gates, channels, selection_ids, encodin return csv -def download_gates(datasource_name, gates, channels): +def download_gates(datasource_name, gates, channels, lassos): global datasource global source global ball_tree @@ -533,10 +533,17 @@ def download_gates(datasource_name, gates, channels): csv.loc[csv['channel'] == channel, 'gate_active'] = True csv.loc[csv['channel'] == channel, 'gate_start'] = gates[channel][0] csv.loc[csv['channel'] == channel, 'gate_end'] = gates[channel][1] + + if len(lassos) > 0: + csv_count = 0 + for key, value in lassos.items(): + csv.loc[len(csv)+csv_count] = ['Lasso', value['lasso_polygon'], np.nan, value['lasso_toggle']] + csv_count+=1 + return csv -def save_gating_list(datasource_name, gates, channels): +def save_gating_list(datasource_name, gates, channels, lassos): global datasource global source global ball_tree @@ -555,6 +562,12 @@ def save_gating_list(datasource_name, gates, channels): csv.loc[csv['channel'] == channel, 'gate_start'] = gates[channel][0] csv.loc[csv['channel'] == channel, 'gate_end'] = gates[channel][1] + if len(lassos) > 0: + csv_count = 0 + for key, value in lassos.items(): + csv.loc[len(csv)+csv_count] = ['Lasso', value['lasso_polygon'], np.nan, value['lasso_toggle']] + csv_count+=1 + temp = csv.to_dict(orient='records') f = pickle.dumps(temp, protocol=4) database_model.save_list(database_model.GatingList, datasource=datasource_name, cells=f) @@ -923,11 +936,29 @@ def get_cells_in_polygon(datasource_name, points): neighbor_ids = neighbor_points[np.where(inside == True), 0].astype('int').flatten().tolist() neighbor_ids.sort() - neighbor_ids_subtract = list(set(datasource['CellID']) - set(neighbor_ids)) - neighbor_ids_subtract.sort() + packet = neighbor_ids + return packet + +def get_cells_in_lassos(datasource_name, list_lassos): + global config + global datasource + global ball_tree + + if datasource_name != source: + load_datasource(datasource_name) + + list_lassos_active = {k: v for k, v in list_lassos.items() if v.get('lasso_toggle') == True} + + list_ids = [] + list_ids_subtract = [] + for v in list_lassos_active.values(): + list_ids.extend(v.get('lasso_ids', [])) + list_ids_subtract.extend(v.get('lasso_ids_subtract', [])) + list_ids = list(set(list_ids)) + list_ids.sort() - packet = {} - packet['list_ids'] = neighbor_ids - packet['list_ids_subtract'] = neighbor_ids_subtract + list_ids_subtract = list(set(datasource['CellID']) - set(list_ids)) + list_ids_subtract.sort() + packet = {'lasso_ids': list_ids, 'lasso_ids_subtract': list_ids_subtract} return packet diff --git a/minerva_analysis/server/routes/data_routes.py b/minerva_analysis/server/routes/data_routes.py index 3a8cc59c0..4c58c930c 100644 --- a/minerva_analysis/server/routes/data_routes.py +++ b/minerva_analysis/server/routes/data_routes.py @@ -222,6 +222,7 @@ def download_gating_csv(): filter = json.loads(request.form['filter']) channels = json.loads(request.form['channels']) + lassos = json.loads(request.form['lassos']) selection_ids = json.loads(request.form['selection_ids']) fullCsv = json.loads(request.form['fullCsv']) encoding = request.form['encoding'] @@ -233,7 +234,7 @@ def download_gating_csv(): headers={"Content-disposition": "attachment; filename=" + filename + ".csv"}) else: - csv = data_model.download_gates(datasource, filter, channels) + csv = data_model.download_gates(datasource, filter, channels, lassos) return Response( csv.to_csv(index=False), mimetype="text/csv", @@ -247,8 +248,9 @@ def save_gating_list(): datasource = post_data['datasource'] filter = post_data['filter'] channels = post_data['channels'] + lassos = post_data['lassos'] - data_model.save_gating_list(datasource, filter, channels) + data_model.save_gating_list(datasource, filter, channels, lassos) resp = jsonify(success=True) return resp @@ -344,3 +346,11 @@ def get_cells_in_polygon(): points = post_data['points'] resp = data_model.get_cells_in_polygon(datasource, points) return serialize_and_submit_json(resp) + +@app.route('/get_cells_in_lassos', methods=['POST']) +def get_cells_in_lassos(): + post_data = json.loads(request.data) + datasource = post_data['datasource'] + list_lassos = post_data['list_lassos'] + resp = data_model.get_cells_in_lassos(datasource, list_lassos) + return serialize_and_submit_json(resp) diff --git a/requirements.yml b/requirements.yml index 65c0371d5..1ef5f5702 100644 --- a/requirements.yml +++ b/requirements.yml @@ -18,7 +18,7 @@ dependencies: - python=3.9.15 - requests - scikit-image - - scikit-learn + - scikit-learn=1.2.2 - scipy - tifffile=2021.4.8 - waitress @@ -27,5 +27,5 @@ dependencies: - pip: - opencv-python==4.7.0.68 - - + - imagecodecs[all] + - xmlschema From abb374854da1ea3fcc7ff8abd6c7aa0552862782 Mon Sep 17 00:00:00 2001 From: Nhan Huynh Date: Thu, 5 Oct 2023 19:12:22 -0400 Subject: [PATCH 6/7] Fixed autoGate to update gate values after lasso selection --- minerva_analysis/client/src/js/views/csvGatingList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minerva_analysis/client/src/js/views/csvGatingList.js b/minerva_analysis/client/src/js/views/csvGatingList.js index 2365c4186..d25109be2 100644 --- a/minerva_analysis/client/src/js/views/csvGatingList.js +++ b/minerva_analysis/client/src/js/views/csvGatingList.js @@ -318,7 +318,7 @@ class CSVGatingList { */ async autoGate(shortName) { const fullName = this.dataLayer.getFullChannelName(shortName); - const input = (await this.getGatingGMM(fullName, this.seaDragonViewer.selection_ids)).gate.toFixed(7); + const input = this.hasGatingGMM[shortName]['gate'].toFixed(7); const transformed = this.dataLayer.isTransformed(); const gate = transformed ? parseFloat(input) : parseInt(input); if (fullName in this.selections) { From b56b78f93ea843e186102acd902ba3a596be17aa Mon Sep 17 00:00:00 2001 From: Nhan Huynh Date: Thu, 5 Oct 2023 20:07:40 -0400 Subject: [PATCH 7/7] Fixed autoGate to update slider box --- .../client/src/js/views/csvGatingList.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/minerva_analysis/client/src/js/views/csvGatingList.js b/minerva_analysis/client/src/js/views/csvGatingList.js index 15e68cafb..8c164d442 100644 --- a/minerva_analysis/client/src/js/views/csvGatingList.js +++ b/minerva_analysis/client/src/js/views/csvGatingList.js @@ -322,10 +322,13 @@ class CSVGatingList { const transformed = this.dataLayer.isTransformed(); const gate = transformed ? parseFloat(input) : parseInt(input); if (fullName in this.selections) { + const channelID = this.gatingIDs[shortName]; const gate_end = this.selections[fullName][1]; const slider = this.sliders.get(shortName); const values = [gate, gate_end]; this.moveSliderHandles(slider, values, shortName, 'GATING_BRUSH_END'); + d3.select('#gating_slider-input_' + channelID + '_0').attr('value', gate) + d3.select('#gating_slider-input_' + channelID + '_0').property('value', gate); } } @@ -589,10 +592,10 @@ class CSVGatingList { const transformed = this.dataLayer.isTransformed(); const v0 = transformed ? range[0] : Math.round(range[0]); const v1 = transformed ? range[1] : Math.round(range[1]); - d3.select('#gating_slider-input' + channelID + 0).attr('value', v0) - d3.select('#gating_slider-input' + channelID + 0).property('value', v0); - d3.select('#gating_slider-input' + channelID + 1).attr('value', v1); - d3.select('#gating_slider-input' + channelID + 1).property('value', v1); + d3.select('#gating_slider-input_' + channelID + '_0').attr('value', v0) + d3.select('#gating_slider-input_' + channelID + '_0').property('value', v0); + d3.select('#gating_slider-input_' + channelID + '_1').attr('value', v1); + d3.select('#gating_slider-input_' + channelID + '_1').property('value', v1); this.moveSliderHandles(sliderSimple, [v0, v1], name, "GATING_BRUSH_MOVE"); }); @@ -653,7 +656,7 @@ class CSVGatingList { .style('background', 'none') .append('input') .attr( 'y', -17) - .attr('id', 'gating_slider-input' + channelID + i) + .attr('id', 'gating_slider-input_' + channelID + '_' + i) .attr('type', 'text') .attr('class', 'input') .attr('value', () => {