diff --git a/web_external/js/models/AnnotationModel.js b/web_external/js/models/AnnotationModel.js index 73bbb13b..88884951 100644 --- a/web_external/js/models/AnnotationModel.js +++ b/web_external/js/models/AnnotationModel.js @@ -1,3 +1,11 @@ isic.models.AnnotationModel = girder.Model.extend({ - resourceName: 'annotation' + resourceName: 'annotation', + + isComplete: function () { + return this.get('state') === 'active'; + }, + + image: function () { + return new isic.models.ImageModel(this.get('image')); + } }); diff --git a/web_external/js/views/StudyResults/StudyResultsLocalFeaturesView.js b/web_external/js/views/StudyResults/StudyResultsLocalFeaturesView.js new file mode 100644 index 00000000..496305f7 --- /dev/null +++ b/web_external/js/views/StudyResults/StudyResultsLocalFeaturesView.js @@ -0,0 +1,145 @@ +isic.views.StudyResultsLocalFeaturesView = isic.View.extend({ + /** + * Create a view to select and display local features of an annotation. + * + * @param {Object} settings - Parameters for the view. + * @param {HTMLElement} settings.el - Element to attach this view to. + * @param {isic.models.AnnotationModel} settings.annotation - The annotation + * model; this may change and this view will update. + * @param {isic.models.FeaturesetModel} settings.featureset - The featureset + * of the study for the annotation; this should never change (delete and + * re-instantiate this view for a new study). + * @param {girder.View} settings.parentView - View that instantiated this view. + */ + initialize: function (settings) { + this.annotation = settings.annotation; + this.featureset = settings.featureset; + + this.render(); + + this.listenTo(this.annotation, 'change', this.annotationChanged); + this.listenTo(this.featureset, 'change', this.render); + }, + + render: function () { + if (!this.featureset.id) { + // TODO: this should not happen + this.$el.html('N/A'); + return this; + } + this.$el.html(isic.templates.studyResultsLocalFeaturesPage({ + hasLocalFeatures: !_.isEmpty(this.featureset.get('localFeatures')) + })); + + this.selectFeatureView = new isic.views.StudyResultsLocalFeaturesSelectView({ + el: this.$('#isic-study-results-local-features-select-container'), + annotation: this.annotation, + featureset: this.featureset, + parentView: this + }); + this.overlayImageViewerWidget = new isic.views.OverlayImageViewerWidget({ + el: this.$('#isic-study-results-local-features-image-container'), + model: this.annotation.image(), + parentView: this + }).render(); + + this.selectedFeatureId = null; + this.listenTo(this.selectFeatureView, 'changed', this.selectedFeatureChanged); + + return this; + }, + + selectedFeatureChanged: function (featureId) { + if (featureId === this.selectedFeatureId) { + return; + } + this.selectedFeatureId = featureId; + + if (this.selectedFeatureId && this.annotation.isComplete()) { + var annotationValue = this.annotation.get('annotations')[this.selectedFeatureId]; + if (annotationValue) { + this.overlayImageViewerWidget.overlay(annotationValue); + return; + } + } + this.overlayImageViewerWidget.clear(); + }, + + annotationChanged: function () { + this.selectedFeatureId = null; + + // this.selectFeatureView updates itself when this.annotation changes + + // TODO: this.overlayImageViewerWidget should probably expose a method + // to update its model + var overlayImageModel = this.overlayImageViewerWidget.model; + overlayImageModel.clear({silent: true}); + overlayImageModel.set(this.annotation.image().attributes); + }, + + setVisible: function (visible) { + if (visible) { + this.$el.removeClass('hidden'); + } else { + this.$el.addClass('hidden'); + } + } +}); + +// View for a collection of local features in a select tag +isic.views.StudyResultsLocalFeaturesSelectView = isic.View.extend({ + events: { + 'change': 'selectedFeatureChanged' + }, + + initialize: function (settings) { + this.annotation = settings.annotation; + this.featureset = settings.featureset; + + this.render(); + + this.listenTo(this.annotation, 'change', this.annotationChanged); + }, + + render: function () { + var availableFeatures; + if (this.annotation.isComplete()) { + // Annotation is complete + var featuresetLocalFeatures = this.featureset.get('localFeatures'); + var annotationValues = this.annotation.get('annotations'); + availableFeatures = featuresetLocalFeatures.filter(_.bind(function (feature) { + return _.has(annotationValues, feature.id); + }, this)); + } else { + // Annotation is pending + availableFeatures = []; + } + + this.$el.html(isic.templates.studyResultsLocalFeaturesSelectPage({ + availableFeatures: availableFeatures + })); + + // Set up select box + var placeholder = _.isEmpty(availableFeatures) + ? 'No features available' + : 'Select a feature... (' + availableFeatures.length + ' available)'; + var select = this.$('#isic-study-results-local-features-select-box'); + select.select2({ + placeholder: placeholder + }); + + return this; + }, + + selectedFeatureChanged: function () { + this.trigger('changed', this.$('select').val()); + }, + + annotationChanged: function () { + // Destroy previous select2 + var select = this.$('#isic-study-results-local-features-select-box'); + select.select2('destroy'); + + this.render(); + } +}); diff --git a/web_external/js/views/StudyResults/StudyResultsView.js b/web_external/js/views/StudyResults/StudyResultsView.js index d5528294..40cee96f 100644 --- a/web_external/js/views/StudyResults/StudyResultsView.js +++ b/web_external/js/views/StudyResults/StudyResultsView.js @@ -2,17 +2,6 @@ // Annotation study results view // -// Model for a feature -isic.models.FeatureModel = Backbone.Model.extend({ - name: function () { - return this.get('name'); - } -}); - -// Model for a feature image -isic.models.FeatureImageModel = Backbone.Model.extend({ -}); - // Model for a global feature result isic.models.GlobalFeatureResultModel = Backbone.Model.extend({ name: function () { @@ -20,26 +9,6 @@ isic.models.GlobalFeatureResultModel = Backbone.Model.extend({ } }); -// Collection of feature models -isic.collections.FeatureCollection = Backbone.Collection.extend({ - model: isic.models.FeatureModel, - - // Update collection from an array of features of the form: - // { 'id': id, 'name': [name1, name2, ...] } - update: function (features) { - var models = _.map(features, function (feature) { - var featureId = feature['id']; - var featureNames = feature['name']; - var model = new isic.models.FeatureModel({ - id: featureId, - name: featureNames.join(', ') - }); - return model; - }); - this.reset(models); - } -}); - // Header view for collection of images isic.views.StudyResultsImageHeaderView = isic.View.extend({ initialize: function (options) { @@ -206,53 +175,6 @@ isic.views.StudyResultsSelectUsersView = isic.View.extend({ } }); -// View for a collection of local features in a select tag -isic.views.StudyResultsSelectLocalFeaturesView = isic.View.extend({ - events: { - 'change': 'featureChanged' - }, - - initialize: function (options) { - this.featureAnnotated = options.featureAnnotated; - - this.listenTo(this.collection, 'reset', this.render); - - this.render(); - }, - - featureChanged: function () { - this.trigger('changed', this.$('select').val()); - }, - - render: function () { - // Destroy previous select2 - var select = this.$('#isic-study-results-select-local-features-select'); - select.select2('destroy'); - - // Create local collection of those features that are annotated - var collection = this.collection.clone(); - collection.reset(collection.filter(_.bind(function (model) { - return this.featureAnnotated(model.id); - }, this))); - - this.$el.html(isic.templates.studyResultsSelectLocalFeaturesPage({ - models: collection.models - })); - - // Set up select box - var placeholder = 'No features available'; - if (!collection.isEmpty()) { - placeholder = 'Select a feature... (' + collection.length + ' available)'; - } - select = this.$('#isic-study-results-select-local-features-select'); - select.select2({ - placeholder: placeholder - }); - - return this; - } -}); - // Collection of global feature result models isic.collections.GlobalFeatureResultCollection = Backbone.Collection.extend({ model: isic.models.GlobalFeatureResultModel, @@ -334,111 +256,6 @@ isic.views.StudyResultsGlobalFeaturesView = isic.View.extend({ } }); -// View for a local feature image defined by an annotation and local feature -isic.views.StudyResultsFeatureImageView = isic.View.extend({ - initialize: function (settings) { - this.listenTo(this.model, 'change', this.render); - }, - - setVisible: function (visible) { - if (visible) { - this.$el.removeClass('hidden'); - } else { - this.$el.addClass('hidden'); - } - }, - - render: function () { - var featureId = this.model.get('featureId'); - var annotationId = this.model.get('annotationId'); - var imageUrl = null; - if (featureId && annotationId) { - imageUrl = [ - girder.apiRoot, - 'annotation', annotationId, - 'render?contentDisposition=inline&featureId=' - ].join('/') + encodeURIComponent(featureId); - } - - this.$el.html(isic.templates.studyResultsFeatureImagePage({ - imageUrl: imageUrl - })); - - return this; - } -}); - -// View to allow selecting a local feature from a featureset and to display an -// image showing the annotation for the feature -isic.views.StudyResultsLocalFeaturesView = isic.View.extend({ - initialize: function (settings) { - this.annotation = settings.annotation; - this.featureset = settings.featureset; - this.featureImageModel = settings.featureImageModel; - this.features = new isic.collections.FeatureCollection(); - - this.listenTo(this.featureset, 'change', this.featuresetChanged); - this.listenTo(this.annotation, 'change', this.annotationChanged); - - this.selectFeatureView = new isic.views.StudyResultsSelectLocalFeaturesView({ - collection: this.features, - featureAnnotated: _.bind(this.featureAnnotated, this), - parentView: this - }); - - this.listenTo(this.selectFeatureView, 'changed', this.featureChanged); - }, - - featureChanged: function (featureId) { - this.featureId = featureId; - this.updateFeatureImageModel(); - }, - - updateFeatureImageModel: function () { - this.featureImageModel.set({ - featureId: this.featureId, - annotationId: this.featureAnnotated(this.featureId) ? this.annotation.id : null - }); - }, - - render: function () { - this.$el.html(isic.templates.studyResultsLocalFeaturesPage({ - hasLocalFeatures: !_.isEmpty(this.featureset.get('localFeatures')), - featureset: this.featureset - })); - - this.selectFeatureView.setElement( - this.$('#isic-study-results-select-local-feature-container')).render(); - - return this; - }, - - updateFeatureCollection: function () { - delete this.featureId; - - this.features.update(this.featureset.get('localFeatures')); - }, - - featuresetChanged: function () { - this.updateFeatureCollection(); - this.render(); - }, - - annotationChanged: function () { - this.featureId = null; - this.updateFeatureImageModel(); - this.render(); - }, - - featureAnnotated: function (featureId) { - if (!featureId || !this.annotation.has('annotations')) { - return false; - } - var annotations = this.annotation.get('annotations'); - return _.has(annotations, featureId); - } -}); - // View for an image isic.views.StudyResultsImageView = isic.View.extend({ setVisible: function (visible) { @@ -470,20 +287,20 @@ isic.views.StudyResultsView = isic.View.extend({ events: { // Update image visibility when image preview tab is activated 'shown.bs.tab #isic-study-results-image-preview-tab': function (event) { - this.localFeaturesImageView.setVisible(false); + this.localFeaturesView.setVisible(false); this.imageView.setVisible(true); }, // Update image visibility when global features tab is activated 'shown.bs.tab #isic-study-results-global-features-tab': function (event) { this.imageView.setVisible(false); - this.localFeaturesImageView.setVisible(false); + this.localFeaturesView.setVisible(false); }, // Update image visibility when local features tab is activated 'shown.bs.tab #isic-study-results-local-features-tab': function (event) { this.imageView.setVisible(false); - this.localFeaturesImageView.setVisible(true); + this.localFeaturesView.setVisible(true); } }, @@ -502,7 +319,6 @@ isic.views.StudyResultsView = isic.View.extend({ this.user = new girder.models.UserModel(); this.featureset = new isic.models.FeaturesetModel(); this.annotation = new isic.models.AnnotationModel(); - this.featureImageModel = new isic.models.FeatureImageModel(); this.selectStudyView = new isic.views.StudyResultsSelectStudyView({ collection: this.studies, @@ -536,23 +352,11 @@ isic.views.StudyResultsView = isic.View.extend({ parentView: this }); - this.localFeaturesView = new isic.views.StudyResultsLocalFeaturesView({ - annotation: this.annotation, - featureset: this.featureset, - featureImageModel: this.featureImageModel, - parentView: this - }); - this.imageView = new isic.views.StudyResultsImageView({ model: this.image, parentView: this }); - this.localFeaturesImageView = new isic.views.StudyResultsFeatureImageView({ - model: this.featureImageModel, - parentView: this - }); - this.studies.fetch(); this.listenTo(this.selectStudyView, 'changed', this.studyChanged); @@ -661,12 +465,14 @@ isic.views.StudyResultsView = isic.View.extend({ this.$('#isic-study-results-select-user-container')).render(); this.globalFeaturesView.setElement( this.$('#isic-study-results-global-features-container')).render(); - this.localFeaturesView.setElement( - this.$('#isic-study-results-local-features-container')).render(); + this.localFeaturesView = new isic.views.StudyResultsLocalFeaturesView({ + el: this.$('#isic-study-results-local-features-tab-content'), + annotation: this.annotation, + featureset: this.featureset, + parentView: this + }).render(); this.imageView.setElement( this.$('#isic-study-results-image-preview-container')).render(); - this.localFeaturesImageView.setElement( - this.$('#isic-study-results-local-features-image-container')).render(); return this; }, diff --git a/web_external/js/views/widgets/ImageViewerWidget.js b/web_external/js/views/widgets/ImageViewerWidget.js index 3c104281..068dc599 100644 --- a/web_external/js/views/widgets/ImageViewerWidget.js +++ b/web_external/js/views/widgets/ImageViewerWidget.js @@ -4,10 +4,10 @@ isic.views.ImageViewerWidget = isic.View.extend({ this.renderedModelId = null; - this.listenTo(this.model, 'change', this.fetchTileInfo); + this.listenTo(this.model, 'change:_id', this._fetchTileInfo); }, - fetchTileInfo: function () { + _fetchTileInfo: function () { if (!this.model.id) { this.destroyViewer(); return; @@ -29,30 +29,7 @@ isic.views.ImageViewerWidget = isic.View.extend({ }, this)); }, - render: function () { - // Do nothing if model is not set - if (!this.model.id) { - return this; - } - - // Ensure tile info is available before rendering - if (_.isUndefined(this.sizeX)) { - this.fetchTileInfo(); - return this; - } - - // Require map element to have a nonzero size - if (this.$el.innerWidth() === 0 || this.$el.innerHeight() === 0) { - return this; - } - - // Do nothing if already rendered for the current model - if (this.model.id === this.renderedModelId) { - return this; - } - - this.renderedModelId = this.model.id; - + _createViewer: function () { // work around a GeoJS sizing bug this.$el.css('font-size', '0'); @@ -95,6 +72,33 @@ isic.views.ImageViewerWidget = isic.View.extend({ '/tiles/zxy/{z}/{x}/{y}' }); this.imageLayer = this.viewer.createLayer('osm', params.layer); + }, + + render: function () { + // Do nothing if model is not set + if (!this.model.id) { + return this; + } + + // Ensure tile info is available before rendering + if (_.isUndefined(this.sizeX)) { + this._fetchTileInfo(); + return this; + } + + // Require map element to have a nonzero size + if (this.$el.innerWidth() === 0 || this.$el.innerHeight() === 0) { + return this; + } + + // Do nothing if already rendered for the current model + if (this.model.id === this.renderedModelId) { + return this; + } + + this.renderedModelId = this.model.id; + + this._createViewer(); return this; }, @@ -119,5 +123,69 @@ isic.views.ImageViewerWidget = isic.View.extend({ isic.View.prototype.destroy.call(this); } +}); + +isic.views.OverlayImageViewerWidget = isic.views.ImageViewerWidget.extend({ + State: { + ABSENT: 0.0, + POSSIBLE: 0.5, + DEFINITE: 1.0 + }, + + _createViewer: function () { + isic.views.ImageViewerWidget.prototype._createViewer.call(this); + this.annotationLayer = this.viewer.createLayer('feature', { + features: ['pixelmap'] + }); + this.pixelmap = this.annotationLayer.createFeature('pixelmap', { + selectionAPI: true, + url: girder.apiRoot + '/image/' + this.model.id + '/superpixels', + position: { + ul: {x: 0, y: 0}, + lr: {x: this.sizeX, y: this.sizeY} + }, + color: _.bind(function (dataValue, index) { + var color = {r: 0, g: 0, b: 0, a: 0}; + var shownAlpha = 0.4; + + if (dataValue === this.State.ABSENT) { + // This could be semi-transparent, to show "definite negative" tiles + color.a = 0.0; + } else if (dataValue === this.State.POSSIBLE) { + color = window.geo.util.convertColor('#fafa00'); + color.a = shownAlpha; + } else if (dataValue === this.State.DEFINITE) { + color = window.geo.util.convertColor('#0000ff'); + color.a = shownAlpha; + } + // TODO: else, log error + return color; + }, this) + }); + this.annotationLayer.draw(); + this.clear(); + }, + + /** + * Remove all displayed overlays. + */ + clear: function () { + this.pixelmap.data([]); + this.pixelmap.visible(false); + this.pixelmap.draw(); + + this.pixelmap.geoOff(); + }, + + /** + * Display a single view-only feature on the viewer. + */ + overlay: function (featureValues) { + this.clear(); + + this.pixelmap.data(featureValues); + this.pixelmap.visible(true); + this.pixelmap.draw(); + } }); diff --git a/web_external/stylesheets/StudyResults/studyResultsLocalFeaturesPage.styl b/web_external/stylesheets/StudyResults/studyResultsLocalFeaturesPage.styl index 526557ad..ca124626 100644 --- a/web_external/stylesheets/StudyResults/studyResultsLocalFeaturesPage.styl +++ b/web_external/stylesheets/StudyResults/studyResultsLocalFeaturesPage.styl @@ -1,26 +1,27 @@ -.isic-study-results-local-features-content - display flex - - &>span - margin-left 20px +#isic-study-results-local-features-select-container + flex 1 - .isic-study-results-local-features-legend - margin-top 10px +.isic-study-results-local-features-legend + margin-top 10px - &>div - display inline - margin-left 20px + &>div + display inline + margin-left 20px - .isic-study-results-local-features-legend-possible - i - color #fafa00 + .isic-study-results-local-features-legend-possible + i + color #fafa00 - .isic-study-results-local-features-legend-definite - i - color #0000ff + .isic-study-results-local-features-legend-definite + i + color #0000ff #isic-study-results-local-features-image-container margin-top 5px - -#isic-study-results-select-local-feature-container + display flex flex 1 + min-width 640px + min-height 480px + object-fit contain + width 100% + height 100% diff --git a/web_external/stylesheets/StudyResults/studyResultsSelectLocalFeaturesPage.styl b/web_external/stylesheets/StudyResults/studyResultsLocalFeaturesSelectPage.styl similarity index 69% rename from web_external/stylesheets/StudyResults/studyResultsSelectLocalFeaturesPage.styl rename to web_external/stylesheets/StudyResults/studyResultsLocalFeaturesSelectPage.styl index c458bc94..7469f94e 100644 --- a/web_external/stylesheets/StudyResults/studyResultsSelectLocalFeaturesPage.styl +++ b/web_external/stylesheets/StudyResults/studyResultsLocalFeaturesSelectPage.styl @@ -1,4 +1,4 @@ -.isic-study-results-select-local-features-container +.isic-study-results-local-features-select-container select // Note that this might not apply, see https://select2.github.io/examples.html#responsive diff --git a/web_external/stylesheets/StudyResults/studyResultsPage.styl b/web_external/stylesheets/StudyResults/studyResultsPage.styl index 82aabf88..f630301a 100644 --- a/web_external/stylesheets/StudyResults/studyResultsPage.styl +++ b/web_external/stylesheets/StudyResults/studyResultsPage.styl @@ -43,20 +43,11 @@ margin-bottom 10px #isic-study-results-image-preview-container - #isic-study-results-local-features-image-container display flex .isic-study-results-image-preview-container flex 1 - .isic-study-results-image - flex 1 - min-width 640px - min-height 480px - object-fit contain - width 100% - height 100% - .nav li a diff --git a/web_external/templates/StudyResults/studyResultsLocalFeaturesPage.jade b/web_external/templates/StudyResults/studyResultsLocalFeaturesPage.jade index 27d3d395..60437f16 100644 --- a/web_external/templates/StudyResults/studyResultsLocalFeaturesPage.jade +++ b/web_external/templates/StudyResults/studyResultsLocalFeaturesPage.jade @@ -1,15 +1,9 @@ -- var hasFeaturesClass = hasLocalFeatures ? '' : 'hidden'; -- var noFeaturesClass = hasLocalFeatures ? 'hidden' : ''; - -.isic-study-results-local-features-content(class=noFeaturesClass) - span N/A - -.isic-study-results-local-features-content(class=hasFeaturesClass) - #isic-study-results-select-local-feature-container - .isic-study-results-local-features-legend - .isic-study-results-local-features-legend-possible - i ■ - span 50% confidence - .isic-study-results-local-features-legend-definite - i ■ - span 100% confidence +#isic-study-results-local-features-select-container +.isic-study-results-local-features-legend + .isic-study-results-local-features-legend-possible + i ■ + span 50% confidence + .isic-study-results-local-features-legend-definite + i ■ + span 100% confidence +#isic-study-results-local-features-image-container diff --git a/web_external/templates/StudyResults/studyResultsLocalFeaturesSelectPage.jade b/web_external/templates/StudyResults/studyResultsLocalFeaturesSelectPage.jade new file mode 100644 index 00000000..e713d370 --- /dev/null +++ b/web_external/templates/StudyResults/studyResultsLocalFeaturesSelectPage.jade @@ -0,0 +1,6 @@ +.isic-study-results-local-features-select-container + // For select2, add width in style attribute, see https://select2.github.io/examples.html#responsive + select#isic-study-results-local-features-select-box.form-control.input(style='width:100%') + option(selected disabled value='') + each feature in availableFeatures + option(value=feature.id)= feature.name.join(', ') diff --git a/web_external/templates/StudyResults/studyResultsPage.jade b/web_external/templates/StudyResults/studyResultsPage.jade index bd9daadf..222ada73 100644 --- a/web_external/templates/StudyResults/studyResultsPage.jade +++ b/web_external/templates/StudyResults/studyResultsPage.jade @@ -40,13 +40,9 @@ #isic-study-results-global-features-container .tab-pane#isic-study-results-local-features-tab-content - #isic-study-results-local-features-container // Container for images associated with tabs. Visibility is controlled by logic in StudyResultsView.js. .isic-study-results-flex-grow.isic-study-results-image-container - // Local features image container - .isic-study-results-flex-grow.hidden#isic-study-results-local-features-image-container - // Image preview container .isic-study-results-flex-grow#isic-study-results-image-preview-container diff --git a/web_external/templates/StudyResults/studyResultsSelectLocalFeaturesPage.jade b/web_external/templates/StudyResults/studyResultsSelectLocalFeaturesPage.jade deleted file mode 100644 index 07fd6f4b..00000000 --- a/web_external/templates/StudyResults/studyResultsSelectLocalFeaturesPage.jade +++ /dev/null @@ -1,6 +0,0 @@ -.isic-study-results-select-local-features-container - // For select2, add width in style attribute, see https://select2.github.io/examples.html#responsive - select#isic-study-results-select-local-features-select.form-control.input(style='width:100%') - option(selected disabled value='') - each model in models - option(value=model.id)= model.name()