diff --git a/.circleci/config.yml b/.circleci/config.yml index 4e10eb4ed0d..96e1dede4c5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -257,12 +257,8 @@ jobs: - run: name: Run screenshot-tests command: | - for i in {1..5}; do # retry - URL=https://master.webknossos.xyz/ \ - yarn test-screenshot && s=0 && break || s=$? - sleep 30 - done - (exit $s) + URL=https://master.webknossos.xyz/ \ + yarn test-screenshot - store_artifacts: path: frontend/javascripts/test/screenshots diff --git a/.eslintrc.json b/.eslintrc.json index 44dce351f96..874d66ec8bf 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -82,7 +82,16 @@ "flow-header/flow-header": "error", "react/sort-comp": [ "error", - { "order": ["type-annotations", "static-methods", "lifecycle", "everything-else", "render"] } + { + "order": [ + "type-annotations", + "instance-variables", + "static-methods", + "lifecycle", + "everything-else", + "render" + ] + } ] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff4d357710..f537c82064e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,18 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). - Neuroglancer precomputed datasets can now be added to webKnossos using the webknossos-connect (wk-connect) service. To setup a wk-connect datastore follow the instructions in the [Readme](https://github.com/scalableminds/webknossos-connect). Afterwards, datasets can be added through "Add Dataset" - "Add Dataset via wk-connect". [#3843](https://github.com/scalableminds/webknossos/pull/3843) - The dataset settings within the tracing view allow to select between different loading strategies now ("best quality first" and "progressive quality"). Additionally, the rendering can use different magnifications as a fallback (instead of only one magnification). [#3801](https://github.com/scalableminds/webknossos/pull/3801) - The mapping selection dropbown is now sorted alphabetically. [#3864](https://github.com/scalableminds/webknossos/pull/3864) +- Added the possibility to filter datasets in the dashboard according to their availability. By default, datasets which are missing on disk (e.g., when the datastore was deleted) are not shown anymore. This behavior can be configured via the settings icon next to the search box in the dashboard. [#3883](https://github.com/scalableminds/webknossos/pull/3883) +- Added merger mode for skeleton and hybrid tracings. It allows to merge segments from e.g. generated segmentations. [#3619](https://github.com/scalableminds/webknossos/pull/3619) - The HTML template now includes SEO tags for demo instances and hides internal instances from search engines. -- A maximize-button was added to the viewports in the annotation view. [#3876](https://github.com/scalableminds/webknossos/pull/3876) +- A maximize-button was added to the viewports in the annotation view. Maximization can also be toggled with the `.` shortcut. [#3876](https://github.com/scalableminds/webknossos/pull/3876) +- [webknossos-connect](https://github.com/scalableminds/webknossos-connect) now starts with webKnossos on local and development instances by default. [#3913](https://github.com/scalableminds/webknossos/pull/3913) ### Changed - Improved the flight mode performance for tracings with very large trees (>80.000 nodes). [#3880](https://github.com/scalableminds/webknossos/pull/3880) - Tweaked the highlighting of the active node. The inner node looks exactly as a non-active node and is not round, anymore. An active node is circled by a "halo". In arbitrary mode, the halo is hidden and the active node is round. [#3868](https://github.com/scalableminds/webknossos/pull/3868) +- Improved the performance of moving through a dataset which should make the overall interaction smoother. [#3902](https://github.com/scalableminds/webknossos/pull/3902) - Brush size is independent of zoom value, now. This change simplifies volume annotations, as brush sizes can be adapted to certain structures (e.g., vesicles) and don't need to be changed when zooming. [#3868](https://github.com/scalableminds/webknossos/pull/3889) +- Reworked the search in the trees tab. [#3878](https://github.com/scalableminds/webknossos/pull/3878) ### Fixed - Fixed a bug where failed large save requests lead to inconsistent tracings on the server. [#3829](https://github.com/scalableminds/webknossos/pull/3829) @@ -31,6 +36,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md). - Fixed interpolation along z-axis. [#3888](https://github.com/scalableminds/webknossos/pull/3888) - Fixed that the halo of the active node could cover other nodes. [#3919](https://github.com/scalableminds/webknossos/pull/3919) - Fixed that the 3D viewport was partially occluded due to clipping distance issues. [#3919](https://github.com/scalableminds/webknossos/pull/3919) +- Fixed that scrolling with the mouse wheel over a data viewport also scrolled the page. This bug appeared with the new Chrome version 73. [#3939](https://github.com/scalableminds/webknossos/pull/3939) ### Removed - Removed FPS meter in Annotation View. [#3916](https://github.com/scalableminds/webknossos/pull/3916) diff --git a/README.md b/README.md index 4e9e793e0a2..92929c46d27 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,13 @@ # webKnossos -Cellular-resolution connectomics is currently substantially limited by the throughput and efficiency of data analysis. -Current solutions require an efficient integration of automated image analysis with massive manual data annotation. -To scale such annotation efforts it is decisive to be able to crowd source data analysis online. -Here we present **webKnossos**. +webKnossos Logo +webKnossos is an open-source tool for annotating and exploring large 3D image datasets. -> Boergens, Berning, Bocklisch, Bräunlein, Drawitsch, Frohnhofen, Herold, Otto, Rzepka, Werkmeister, Werner, Wiese, Wissler and Helmstaedter -webKnossos: efficient online 3D data annotation for connectomics. -[Nature Methods (2017) DOI:10.1038/NMETH.4331.](https://www.nature.com/articles/nmeth.4331) - -![webKnossos logo](https://webknossos.org/images/oxalis.svg) +* Fly through your data for fast skeletonization and proof-reading +* Create 3D training data for automated segmentations efficiently +* Scale data reconstruction projects with crowdsourcing workflows +* Share datasets and annotations with collaborating scientists +[Start using webKnossos](https://webknossos.org) - [User Documentation](https://docs.webknossos.org) - [Contact us](mailto:hello@scalableminds.com) [![]( https://img.shields.io/circleci/project/github/scalableminds/webknossos/master.svg?logo=circleci)](https://circleci.com/gh/scalableminds/webknossos) [![](https://img.shields.io/github/release/scalableminds/webknossos.svg)](https://github.com/scalableminds/webknossos/releases/latest) @@ -28,7 +26,7 @@ webKnossos: efficient online 3D data annotation for connectomics. * User and task management for high-throughput crowdsourcing * Sharing and collaboration features * [Standalone datastore component](https://github.com/scalableminds/webknossos/tree/master/webknossos-datastore) for flexible deployments -* [Supported dataset formats: WKW (Optimized), KNOSSOS cubes](https://github.com/scalableminds/webknossos/wiki/Datasets), [Neuroglancer Precomputed, and BossDB](https://github.com/scalableminds/webknossos-connect) +* [Supported dataset formats: WKW, KNOSSOS cubes](https://github.com/scalableminds/webknossos/wiki/Datasets), [Neuroglancer Precomputed, and BossDB](https://github.com/scalableminds/webknossos-connect) * Supported image formats: Grayscale, Segmentation Maps, RGB, Multi-Channel * [Support for 3D mesh rendering and on-the-fly isosurface generation](https://docs.webknossos.org/guides/mesh_visualization) * [Documented frontend API for user scripts](https://webknossos.org/assets/docs/frontend-api/index.html), REST API for backend access @@ -36,8 +34,15 @@ webKnossos: efficient online 3D data annotation for connectomics. * [Docker-based deployment](https://hub.docker.com/r/scalableminds/webknossos/) for production and development * [Detailed Documentation](https://docs.webknossos.org) +## Publication +> Boergens, Berning, Bocklisch, Bräunlein, Drawitsch, Frohnhofen, Herold, Otto, Rzepka, Werkmeister, Werner, Wiese, Wissler and Helmstaedter +> webKnossos: efficient online 3D data annotation for connectomics. +> [Nature Methods (2017) DOI:10.1038/NMETH.4331.](https://www.nature.com/articles/nmeth.4331) + +[Read more about the original publication.](https://publication.webknossos.org) -## Development setup + +## Development installation ### Docker This is the fastest way to try webKnossos. Docker CE 17+ and Docker Compose 1.18+ is required. @@ -141,9 +146,7 @@ yarn start Will fetch all Scala, Java and node dependencies and run the application on Port 9000. Make sure that the PostgreSQL and Redis services are running before you start the application. -## Production setup -[See wiki](https://github.com/scalableminds/webknossos/wiki/Production-setup) for recommended production setup. - +## Upgrades For upgrades, please check the [changelog](CHANGELOG.md) & [migration guide](MIGRATIONS.md). ## Tests diff --git a/app/controllers/AnnotationIOController.scala b/app/controllers/AnnotationIOController.scala index 7c8cbc8b35b..4de688f9fea 100755 --- a/app/controllers/AnnotationIOController.scala +++ b/app/controllers/AnnotationIOController.scala @@ -125,10 +125,10 @@ class AnnotationIOController @Inject()(nmlWriter: NmlWriter, dataSetName <- assertAllOnSameDataSet(skeletonTracings, volumeTracingsWithDataLocations.headOption.map(_._1)) ?~> "nml.file.differentDatasets" organizationNameOpt <- assertAllOnSameOrganization(parseSuccesses.flatMap(s => s.organizationName)) ?~> "nml.file.differentDatasets" organizationIdOpt <- Fox.runOptional(organizationNameOpt) { - organizationDAO.findOneByName(_).map(_._id) - } ?~> Messages("dataSet.noAccess", dataSetName) ~> FORBIDDEN + organizationDAO.findOneByName(_)(GlobalAccessContext).map(_._id) + } ?~> Messages("dataSet.notFound", dataSetName) ~> FORBIDDEN organizationId <- Fox.fillOption(organizationIdOpt) { - dataSetDAO.getOrganizationForDataSet(dataSetName) + dataSetDAO.getOrganizationForDataSet(dataSetName)(GlobalAccessContext) } ?~> Messages("dataSet.noAccess", dataSetName) ~> FORBIDDEN dataSet <- dataSetDAO.findOneByNameAndOrganization(dataSetName, organizationId) ?~> Messages( "dataSet.noAccess", diff --git a/app/controllers/DataSetController.scala b/app/controllers/DataSetController.scala index fa90e2ba6ed..5c4bad0ec99 100755 --- a/app/controllers/DataSetController.scala +++ b/app/controllers/DataSetController.scala @@ -156,7 +156,7 @@ class DataSetController @Inject()(userService: UserService, def accessList(organizationName: String, dataSetName: String) = sil.SecuredAction.async { implicit request => for { - dataSet <- dataSetDAO.findOneByNameAndOrganization(dataSetName, request.identity._organization) ?~> Messages( + dataSet <- dataSetDAO.findOneByNameAndOrganizationName(dataSetName, organizationName) ?~> Messages( "dataSet.notFound", dataSetName) ~> NOT_FOUND allowedTeams <- dataSetService.allowedTeamIdsFor(dataSet._id) diff --git a/app/controllers/InitialDataController.scala b/app/controllers/InitialDataController.scala index d835bc670bb..bc19efd576e 100644 --- a/app/controllers/InitialDataController.scala +++ b/app/controllers/InitialDataController.scala @@ -94,6 +94,7 @@ Samplecountry for { _ <- insertLocalDataStoreIfEnabled _ <- insertLocalTracingStoreIfEnabled + _ <- insertConnectDataStoreIfEnabled _ <- assertInitialDataEnabled _ <- assertNoOrganizationsPresent _ <- insertOrganization @@ -211,6 +212,16 @@ Samplecountry } } else Fox.successful(()) + def insertConnectDataStoreIfEnabled: Fox[Any] = + if (conf.Application.insertLocalConnectDatastore) { + dataStoreDAO.findOneByName("connect").futureBox.map { maybeStore => + if (maybeStore.isEmpty) { + logger.info("inserting connect datastore") + dataStoreDAO.insertOne(DataStore("connect", "http://localhost:8000", "secret-key", isConnector = true)) + } + } + } else Fox.successful(()) + def insertLocalTracingStoreIfEnabled: Fox[Any] = if (conf.Tracingstore.enabled) { tracingStoreDAO.findOneByName("localhost").futureBox.map { maybeStore => diff --git a/app/models/binary/DataSet.scala b/app/models/binary/DataSet.scala index 27c0d4c7ca7..debd5ed7c8a 100755 --- a/app/models/binary/DataSet.scala +++ b/app/models/binary/DataSet.scala @@ -259,7 +259,10 @@ class DataSetDAO @Inject()(sqlClient: SQLClient, _ <- dataSetDataLayerDAO.updateLayers(old._id, source) } yield () - def deactivateUnreported(names: List[String], organizationId: ObjectId, dataStoreName: String): Fox[Unit] = { + def deactivateUnreported(names: List[String], + organizationId: ObjectId, + dataStoreName: String, + unreportedStatus: String): Fox[Unit] = { val inclusionPredicate = if (names.isEmpty) "true" else s"name not in ${writeStructTupleWithQuotes(names.map(sanitize))}" val deleteResolutionsQuery = @@ -272,7 +275,7 @@ class DataSetDAO @Inject()(sqlClient: SQLClient, and #${inclusionPredicate})""" val setToUnusableQuery = sqlu"""update webknossos.datasets - set isUsable = false, status = 'No longer available on datastore.', scale = NULL + set isUsable = false, status = $unreportedStatus, scale = NULL where _dataStore = ${dataStoreName} and _organization = ${organizationId} and #${inclusionPredicate}""" for { diff --git a/app/models/binary/DataSetService.scala b/app/models/binary/DataSetService.scala index 11da1bb859a..db387ebccf0 100644 --- a/app/models/binary/DataSetService.scala +++ b/app/models/binary/DataSetService.scala @@ -45,6 +45,8 @@ class DataSetService @Inject()(organizationDAO: OrganizationDAO, extends FoxImplicits with LazyLogging { + val unreportedStatus = "No longer available on datastore." + def isProperDataSetName(name: String): Boolean = name.matches("[A-Za-z0-9_\\-]*") @@ -171,7 +173,8 @@ class DataSetService @Inject()(organizationDAO: OrganizationDAO, case Full(organization) => dataSetDAO.deactivateUnreported(dataSourcesByOrganizationName(organizationName).map(_.id.name), organization._id, - dataStoreName) + dataStoreName, + unreportedStatus) case _ => { logger.info(s"Ignoring reported dataset for non-existing organization $organizationName") Fox.successful(()) @@ -252,11 +255,16 @@ class DataSetService @Inject()(organizationDAO: OrganizationDAO, case _ => Fox.successful(0L) } - def allowedTeamIdsFor(_dataSet: ObjectId)(implicit ctx: DBAccessContext) = - dataSetAllowedTeamsDAO.findAllForDataSet(_dataSet)(GlobalAccessContext) ?~> "allowedTeams.notFound" + def allowedTeamIdsFor(_dataSet: ObjectId)(implicit ctx: DBAccessContext): Fox[List[ObjectId]] = + dataSetAllowedTeamsDAO.findAllForDataSet(_dataSet) ?~> "allowedTeams.notFound" - def allowedTeamsFor(_dataSet: ObjectId)(implicit ctx: DBAccessContext) = - teamDAO.findAllForDataSet(_dataSet)(GlobalAccessContext) ?~> "allowedTeams.notFound" + def allowedTeamsFor(_dataSet: ObjectId, requestingUser: Option[User])( + implicit ctx: DBAccessContext): Fox[List[Team]] = + for { + teams <- teamDAO.findAllForDataSet(_dataSet) ?~> "allowedTeams.notFound" + // dont leak team names of other organizations + teamsFiltered = teams.filter(team => requestingUser.map(_._organization).contains(team._organization)) + } yield teamsFiltered def isEditableBy( dataSet: DataSet, @@ -271,22 +279,22 @@ class DataSetService @Inject()(organizationDAO: OrganizationDAO, } def publicWrites(dataSet: DataSet, - userOpt: Option[User], + requestingUserOpt: Option[User], skipResolutions: Boolean = false, - requestingUserTeamManagerMemberships: Option[List[TeamMembership]] = None): Fox[JsObject] = { - implicit val ctx = GlobalAccessContext + requestingUserTeamManagerMemberships: Option[List[TeamMembership]] = None)( + implicit ctx: DBAccessContext): Fox[JsObject] = for { - organization <- organizationDAO.findOne(dataSet._organization) ?~> "organization.notFound" - teams <- allowedTeamsFor(dataSet._id) + organization <- organizationDAO.findOne(dataSet._organization)(GlobalAccessContext) ?~> "organization.notFound" + teams <- allowedTeamsFor(dataSet._id, requestingUserOpt) teamsJs <- Fox.serialCombined(teams)(t => teamService.publicWrites(t)) logoUrl <- logoUrlFor(dataSet, Some(organization)) - isEditable <- isEditableBy(dataSet, userOpt, requestingUserTeamManagerMemberships) - lastUsedByUser <- lastUsedTimeFor(dataSet._id, userOpt) + isEditable <- isEditableBy(dataSet, requestingUserOpt, requestingUserTeamManagerMemberships) + lastUsedByUser <- lastUsedTimeFor(dataSet._id, requestingUserOpt) dataStore <- dataStoreFor(dataSet) dataStoreJs <- dataStoreService.publicWrites(dataStore) dataSource <- dataSourceFor(dataSet, Some(organization), skipResolutions) publicationOpt <- Fox.runOptional(dataSet._publication)(publicationDAO.findOne(_)) - publicationJson <- Fox.runOptional(publicationOpt)(publicationService.publicWrites(_)) + publicationJson <- Fox.runOptional(publicationOpt)(publicationService.publicWrites) } yield { Json.obj( "name" -> dataSet.name, @@ -305,9 +313,9 @@ class DataSetService @Inject()(organizationDAO: OrganizationDAO, "sortingKey" -> dataSet.sortingKey, "details" -> dataSet.details, "publication" -> publicationJson, + "isUnreported" -> Json.toJson(dataSource.statusOpt.contains(unreportedStatus)), "isForeign" -> dataStore.isForeign ) } - } } diff --git a/app/utils/WkConf.scala b/app/utils/WkConf.scala index 8da45400f06..88abe2be36c 100644 --- a/app/utils/WkConf.scala +++ b/app/utils/WkConf.scala @@ -12,6 +12,7 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader { object Application { val insertInitialData = get[Boolean]("application.insertInitialData") + val insertLocalConnectDatastore = get[Boolean]("application.insertLocalConnectDatastore") val title = get[String]("application.title") object Authentication { @@ -125,5 +126,16 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader { } val children = - List(Application, Http, Mail, WebKnossos, Datastore, User, Braintracing, Features, Silhouette, Airbrake, Google) + List(Application, + Http, + Mail, + WebKnossos, + Datastore, + Tracingstore, + User, + Braintracing, + Features, + Silhouette, + Airbrake, + Google) } diff --git a/conf/application.conf b/conf/application.conf index adfc452ccfa..b6897125780 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -64,6 +64,7 @@ braintracing { application { insertInitialData = true + insertLocalConnectDatastore = true authentication { enableDevAutoLogin = false enableDevAutoAdmin = false diff --git a/conf/connect/.gitignore b/conf/connect/.gitignore new file mode 100644 index 00000000000..0ca687ac137 --- /dev/null +++ b/conf/connect/.gitignore @@ -0,0 +1 @@ +datasets.json diff --git a/conf/connect/config.json b/conf/connect/config.json new file mode 100644 index 00000000000..0b758441137 --- /dev/null +++ b/conf/connect/config.json @@ -0,0 +1,7 @@ +{ + "server": {"host": "0.0.0.0", "port": 8000, "url": "http://localhost:8000"}, + "datastore": {"name": "connect", "key": "secret-key"}, + "webknossos": {"url": "http://localhost:9000"}, + "backends": {"neuroglancer": {}}, + "datasets_path": "data/datasets.json" +} diff --git a/docker-compose.yml b/docker-compose.yml index 6623d558908..cf0b0d13f3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,11 +12,14 @@ services: - "fossildb-persisted:fossildb" - "postgres-persisted:postgres" - redis + - webknossos-connect depends_on: postgres-persisted: condition: service_healthy fossildb-persisted: condition: service_healthy + webknossos-connect: + condition: service_healthy command: - -Dconfig.file=conf/application.conf - -Djava.net.preferIPv4Stack=true @@ -122,11 +125,14 @@ services: - "fossildb-dev:fossildb" - "postgres-dev:postgres" - redis + - webknossos-connect depends_on: postgres-dev: condition: service_healthy fossildb-dev: condition: service_healthy + webknossos-connect: + condition: service_healthy environment: - POSTGRES_URL=jdbc:postgresql://postgres/webknossos command: @@ -166,11 +172,14 @@ services: - postgres - fossildb - redis + - webknossos-connect depends_on: postgres: condition: service_healthy fossildb: condition: service_healthy + webknossos-connect: + condition: service_healthy environment: - POSTGRES_URL=jdbc:postgresql://postgres/webknossos_testing command: @@ -195,6 +204,13 @@ services: - ".:/home/pptruser/webknossos" user: ${USER_UID:-1000}:${USER_GID:-1000} + # webKnossos-connect + webknossos-connect: + image: scalableminds/webknossos-connect:master__190 + volumes: + - "./conf/connect:/app/data" + network_mode: host + # Postgres postgres: image: postgres:10-alpine diff --git a/docs/hello.md b/docs/hello.md index ed9f24ac54a..e0cd5f87cfe 100644 --- a/docs/hello.md +++ b/docs/hello.md @@ -5,7 +5,7 @@ The web-based tool is powered by a specialized data-delivery backend that stores webKnossos has a GPU-accelerated viewer that includes tools for creating and sharing annotations (skeletons and volumes). Powerful [user](./users.md) and [task](./tasks.md) management features automatically distribute tasks to human annotators. There are a lot of productivity improvements to make the human part as efficient as possible. -webKnossos is also a platform for [showcasing datasets](https://demo.webknossos.org) alongside a paper publication. +webKnossos is also a platform for [showcasing datasets](https://webknossos.org) alongside a paper publication. > Boergens, Berning, Bocklisch, Bräunlein, Drawitsch, Frohnhofen, Herold, Otto, Rzepka, Werkmeister, Werner, Wiese, Wissler and Helmstaedter webKnossos: efficient online 3D data annotation for connectomics. @@ -14,24 +14,21 @@ webKnossos: efficient online 3D data annotation for connectomics. ![webKnossos logo](https://webknossos.org/images/oxalis.svg) ## Demo -Dataset gallery: [https://demo.webknossos.org/](https://demo.webknossos.org/) -Try webKnossos yourself: [https://try.webknossos.org/](https://try.webknossos.org/) +Try webKnossos on a large selection of published datasets: [https://webknossos.org/](https://webknossos.org/) ## Features * Exploration of large 3D image datasets * Fully browser-based user experience with efficient data streaming -* Creation/editing of [skeleton and volume annotations](./tracing_ui.md) +* Creation/editing of skeleton and volume annotations * [Innovative flight mode for fast skeleton tracing](https://www.nature.com/articles/nmeth.4331) -* Optimized performance for large datasets and annotations -* [User](./users.md) and [task](./tasks.md) management for high-throughput crowdsourcing -* [Sharing and collaboration](./sharing.md) features -* Efficient [keyboard shortcuts](./keyboard_shortcuts.md) -* Undo/Redo functionality +* Optimized performance for large tracings +* User and task management for high-throughput crowdsourcing +* Sharing and collaboration features * [Standalone datastore component](https://github.com/scalableminds/webknossos/tree/master/webknossos-datastore) for flexible deployments -* [Supported dataset formats](./datasets.md): WKW (Optimized), KNOSSOS cubes +* [Supported dataset formats: WKW (Optimized), KNOSSOS cubes](https://github.com/scalableminds/webknossos/wiki/Datasets), [Neuroglancer Precomputed, and BossDB](https://github.com/scalableminds/webknossos-connect) * [Supported image formats](./data_formats.md): Grayscale, Segmentation Maps, RGB, Multi-Channel * [Mesh Visualization](./mesh_visualization.md) -* [Documented frontend API for user scripts](https://demo.webknossos.org/assets/docs/frontend-api/index.html), REST API for backend access +* [Documented frontend API for user scripts](https://webknossos.org/assets/docs/frontend-api/index.html), REST API for backend access * Open-source development with [automated test suite](https://circleci.com/gh/scalableminds/webknossos) * [Docker-based deployment](https://hub.docker.com/r/scalableminds/webknossos/) for production and development diff --git a/docs/keyboard_shortcuts.md b/docs/keyboard_shortcuts.md index 9372a6452e1..407447b58a3 100644 --- a/docs/keyboard_shortcuts.md +++ b/docs/keyboard_shortcuts.md @@ -18,6 +18,8 @@ Find all available keyboard shortcuts for webKnossos listed below. | 3 | Toggle Segmentation Opacity | | H | Increase the Move Value | | G | Decrease the Move Value | +| Q | Download Screenshot(s) of Viewport(s) | +| . | Toggle Viewport Maximization | ## Skeleton Tracings diff --git a/docs/sharing.md b/docs/sharing.md index b46a0dc9c3f..af51af8e489 100644 --- a/docs/sharing.md +++ b/docs/sharing.md @@ -9,7 +9,7 @@ To manage access rights to certain datasets for webKnossos users check out [the Dataset sharing allows outside users to view your datasets and segmentation layers within webKnossos. Shared resources can be accessed through a direct URL or can be featured on a spotlight gallery for showcasing your work. -[Please contact us](mailto:hello@scalableminds.com) to feature your dataset on https://demo.webknossos.org. +[Please contact us](mailto:hello@scalableminds.com) to feature your dataset on https://webknossos.org. Sharing a dataset is useful for multiple scenarios: - You recorded a novel microscopy dataset and want to include links to it in your paper or for reviewers. Use wklink.org to shorten these URLs, e.g. https://wklink.org/5386 ([contact us](mailto:hello@scalableminds.com)). diff --git a/docs/tracing_ui.md b/docs/tracing_ui.md index 95df635a736..ced0b8a1807 100644 --- a/docs/tracing_ui.md +++ b/docs/tracing_ui.md @@ -19,7 +19,7 @@ The most common buttons are: - `Archive`: Only available for Explorative Annotations. Closes the annotation and archives it, removing it from a user's dashboard. Archived annotations can be found on a user's dashboard under "Explorative Annotations" and by clicking on "Show Archived Annotations". Use this to declutter your dashboard. - `Download`: Starts the download of the current annotation. Skeleton annotations are downloaded as [NML](./data_formats.md#nml) files. Volume annotation downloads contain the raw segmentation data as [WKW](./data_formats.md#wkw) files. - `Share`: Create a shareable link to your dataset containing the current position, rotation, zoom level etc. Use this to collaboratively work with colleagues. Read more about this feature in the [Sharing guide](./sharing.md). -- `Add Script`: Using the [webKnossos frontend API](https://demo.webknossos.org/assets/docs/frontend-api/index.html) users can interact with webKnossos programmatically. User scripts can be executed from here. Admins can add often used scripts to webKnossos to make them available to all users for easy access. +- `Add Script`: Using the [webKnossos frontend API](https://webknossos.org/assets/docs/frontend-api/index.html) users can interact with webKnossos programmatically. User scripts can be executed from here. Admins can add often used scripts to webKnossos to make them available to all users for easy access. - `Restore Older Version`: Opens a view that shows all previous versions of a tracing. From this view, any older version can be selected, previewed, and restored. A user can directly jump to positions within their datasets by entering them in the position input field. @@ -209,6 +209,10 @@ In the `Segmentation` tab on the right-hand side, you can see the cell IDs which ![Adding labels with the Brush tool](./images/volume_brush.gif) ![Removing labels with the Brush tool](./images/volume_delete.gif) +### Merging Segments + +Segments from e.g. automatic segmentations can be merged with the merger mode to refine the result. The merger mode is available in skeleton and hybrid tracings. Each tree represents a merged segment and its nodes define the segments that are merged together. So each new merged segment needs its own tree. The merger mode can be enabled in the settings in the category Nodes & Trees under the option Enable Merger Mode. As soon as you enable it, all already existing trees will be used to form merged segments. + ## Hybrid Annotations Hybrid annotations combine the functionality of skeleton and volume annotations. @@ -284,3 +288,4 @@ For multi-layer datasets, each layer can be adjusted separately. - `4 Bit`: Toggles data download from the server using only 4 Bit instead of 8 Bit for each pixel. Use this to reduce the amount of necessary internet bandwidth for webKnossos. Useful for showcasing data on the go over cellular networks, e.g 3G. - `Interpolation`: When interpolation is enabled, bilinear filtering is applied while rendering pixels between two voxels. As a result, data may look "smoother" (or blurry when being zoomed in very far). Without interpolation, data may look more "crisp" (or pixelated when being zomed in very far). - `Render Missing Data Black`: If a dataset doesn't contain data at a specific position, webKnossos can either render "black" at that position or it can try to render data from another magnification. + diff --git a/frontend/javascripts/admin/api_flow_types.js b/frontend/javascripts/admin/api_flow_types.js index 2f7d0f9217e..2a58b4e5738 100644 --- a/frontend/javascripts/admin/api_flow_types.js +++ b/frontend/javascripts/admin/api_flow_types.js @@ -111,6 +111,7 @@ export type APIDatasetDetails = { }; type APIDatasetBase = APIDatasetId & { + +isUnreported: boolean, +allowedTeams: Array, +created: number, +dataStore: APIDataStore, diff --git a/frontend/javascripts/admin/auth/change_password_view.js b/frontend/javascripts/admin/auth/change_password_view.js index 0b488d3f238..96ed10301f5 100644 --- a/frontend/javascripts/admin/auth/change_password_view.js +++ b/frontend/javascripts/admin/auth/change_password_view.js @@ -58,7 +58,7 @@ class ChangePasswordView extends React.PureComponent { checkPassword = (rule, value, callback) => { const form = this.props.form; if (value && value !== form.getFieldValue("password.password1")) { - callback(messages["auth.registration_password_missmatch"]); + callback(messages["auth.registration_password_mismatch"]); } else { callback(); } diff --git a/frontend/javascripts/admin/auth/finish_reset_password_view.js b/frontend/javascripts/admin/auth/finish_reset_password_view.js index bf654408962..1d954451354 100644 --- a/frontend/javascripts/admin/auth/finish_reset_password_view.js +++ b/frontend/javascripts/admin/auth/finish_reset_password_view.js @@ -44,21 +44,21 @@ class FinishResetPasswordView extends React.PureComponent { }; handleConfirmBlur = (e: SyntheticInputEvent<>) => { - const value = e.target.value; + const { value } = e.target; this.setState(prevState => ({ confirmDirty: prevState.confirmDirty || !!value })); }; checkPassword = (rule, value, callback) => { - const form = this.props.form; + const { form } = this.props; if (value && value !== form.getFieldValue("password.password1")) { - callback(messages["auth.registration_password_missmatch"]); + callback(messages["auth.registration_password_mismatch"]); } else { callback(); } }; checkConfirm = (rule, value, callback) => { - const form = this.props.form; + const { form } = this.props; if (value && this.state.confirmDirty) { form.validateFields(["confirm"], { force: true }); } diff --git a/frontend/javascripts/admin/auth/registration_form.js b/frontend/javascripts/admin/auth/registration_form.js index 6c82b005d52..f3244729f8e 100644 --- a/frontend/javascripts/admin/auth/registration_form.js +++ b/frontend/javascripts/admin/auth/registration_form.js @@ -103,7 +103,7 @@ class RegistrationForm extends React.PureComponent { checkPassword = (rule, value, callback) => { const { form } = this.props; if (value && value !== form.getFieldValue("password.password1")) { - callback(messages["auth.registration_password_missmatch"]); + callback(messages["auth.registration_password_mismatch"]); } else { callback(); } diff --git a/frontend/javascripts/admin/help/keyboardshortcut_view.js b/frontend/javascripts/admin/help/keyboardshortcut_view.js index 33bb91b4d1b..1f2d1f1f692 100644 --- a/frontend/javascripts/admin/help/keyboardshortcut_view.js +++ b/frontend/javascripts/admin/help/keyboardshortcut_view.js @@ -99,7 +99,7 @@ const KeyboardShortcutView = () => { }, { keybinding: "M", - action: "Toggle Mode (orthogonal, Flight, Oblique)", + action: "Toggle Mode (Orthogonal, Flight, Oblique)", }, { keybinding: "1", @@ -125,6 +125,14 @@ const KeyboardShortcutView = () => { keybinding: "Ctrl + Shift + F", action: "Open Tree Search (if Tree List is visible)", }, + { + keybinding: "Q", + action: "Download Screenshot(s) of Viewport(s)", + }, + { + keybinding: ".", + action: "Toggle Viewport Maximization", + }, ]; const flightModeShortcuts = [ diff --git a/frontend/javascripts/components/brain_spinner.js b/frontend/javascripts/components/brain_spinner.js index 41dad46cc6d..3a09d114529 100644 --- a/frontend/javascripts/components/brain_spinner.js +++ b/frontend/javascripts/components/brain_spinner.js @@ -19,7 +19,7 @@ export default function BrainSpinner() {
Abstract brain { + intervalId: ?number = null; + componentDidMount() { this.intervalId = window.setInterval(this.props.onTick, this.props.interval); } @@ -20,8 +22,6 @@ class Loop extends Component { } } - intervalId: ?number = null; - render() { return null; } diff --git a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js index 1677896b0b5..688b54c02f2 100644 --- a/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js +++ b/frontend/javascripts/dashboard/advanced_dataset/dataset_table.js @@ -11,6 +11,7 @@ import DatasetAccessListView from "dashboard/advanced_dataset/dataset_access_lis import DatasetActionView from "dashboard/advanced_dataset/dataset_action_view"; import FormattedDate from "components/formatted_date"; import * as Utils from "libs/utils"; +import type { DatasetFilteringMode } from "../dataset_view"; const { Column } = Table; @@ -21,6 +22,7 @@ type Props = { datasets: Array, searchQuery: string, isUserAdmin: boolean, + datasetFilteringMode: DatasetFilteringMode, }; type State = { @@ -59,14 +61,45 @@ class DatasetTable extends React.PureComponent { }); }; + getFilteredDatasets() { + const filterByMode = datasets => { + const { datasetFilteringMode } = this.props; + if (datasetFilteringMode === "onlyShowReported") { + return datasets.filter(el => !el.isUnreported); + } else if (datasetFilteringMode === "onlyShowUnreported") { + return datasets.filter(el => el.isUnreported); + } else { + return datasets; + } + }; + + const filterByQuery = datasets => + Utils.filterWithSearchQueryAND( + datasets, + ["name", "description"], + this.props.searchQuery, + ); + + const filterByHasLayers = datasets => + this.props.isUserAdmin + ? datasets + : datasets.filter(dataset => dataset.dataSource.dataLayers != null); + + return filterByQuery(filterByMode(filterByHasLayers(this.props.datasets))); + } + + renderEmptyText() { + const maybeWarning = + this.props.datasetFilteringMode !== "showAllDatasets" + ? "Note that datasets are currently filtered according to whether they are available on the datastore. You can change the filtering via the menu next to the search input." + : null; + + return No Datasets found. {maybeWarning}; + } + render() { const { isUserAdmin } = this.props; - const isImported = dataset => dataset.dataSource.dataLayers != null; - const filteredDataSource = Utils.filterWithSearchQueryAND( - isUserAdmin ? this.props.datasets : this.props.datasets.filter(isImported), - ["name", "description"], - this.props.searchQuery, - ); + const filteredDataSource = this.getFilteredDatasets(); const { sortedInfo } = this.state; const dataSourceSortedByRank = useLruRank @@ -110,6 +143,7 @@ class DatasetTable extends React.PureComponent { isUserAdmin ? dataset => : null } onChange={this.handleChange} + locale={{ emptyText: this.renderEmptyText() }} > , isLoading: boolean, searchQuery: string, + datasetFilteringMode: DatasetFilteringMode, }; const persistence: Persistence = new Persistence( - { searchQuery: PropTypes.string }, + { + searchQuery: PropTypes.string, + datasetFilteringMode: PropTypes.oneOf([ + "showAllDatasets", + "onlyShowReported", + "onlyShowUnreported", + ]), + }, "datasetList", ); @@ -51,6 +61,7 @@ class DatasetView extends React.PureComponent { searchQuery: "", datasets: datasetCache.get(), isLoading: false, + datasetFilteringMode: "onlyShowReported", }; componentWillMount() { @@ -172,43 +183,83 @@ class DatasetView extends React.PureComponent { renderTable() { return ( - ); } render() { const margin = { marginRight: 5 }; - const search = ( + const isUserAdmin = Utils.isUserAdmin(this.props.user); + const createFilteringModeRadio = (key, label) => ( + this.setState({ datasetFilteringMode: key })} + checked={this.state.datasetFilteringMode === key} + > + {label} + + ); + + const filterMenu = ( + {}}> + {createFilteringModeRadio("showAllDatasets", "Show all datasets")} + + {createFilteringModeRadio("onlyShowReported", "Only show available datasets")} + + + {createFilteringModeRadio("onlyShowUnreported", "Only show missing datasets")} + + + ); + const searchBox = ( ); - - const adminHeader = Utils.isUserAdmin(this.props.user) ? ( -
- - - - - {search} -
+ + ) : ( - search + searchBox + ); + + const adminHeader = ( +
+ {isUserAdmin ? ( + + + + + + {search} + + ) : ( + search + )} +
); const isEmpty = this.state.datasets.length === 0; @@ -219,6 +270,7 @@ class DatasetView extends React.PureComponent { {adminHeader}

Datasets

+ {content} diff --git a/frontend/javascripts/libs/deferred.js b/frontend/javascripts/libs/deferred.js index d476d3f2c88..32009ca8992 100644 --- a/frontend/javascripts/libs/deferred.js +++ b/frontend/javascripts/libs/deferred.js @@ -15,10 +15,10 @@ class Deferred { // ``` // d = new Deferred() // setTimeout( - // -> d.internalResolve() + // () => d.resolve(), // 1000 // ) - // return d.internalPromise() + // d.promise().then(...) // ``` constructor() { diff --git a/frontend/javascripts/libs/input.js b/frontend/javascripts/libs/input.js index ae825cd7745..5393da5c46c 100644 --- a/frontend/javascripts/libs/input.js +++ b/frontend/javascripts/libs/input.js @@ -385,7 +385,13 @@ export class InputMouse { ); _.extend( this.delegatedEvents, - Utils.addEventListenerWithDelegation(document, "wheel", this.targetSelector, this.mouseWheel), + Utils.addEventListenerWithDelegation( + document, + "wheel", + this.targetSelector, + this.mouseWheel, + { passive: false }, + ), ); this.hammerManager = new Hammer(this.domElement, { diff --git a/frontend/javascripts/libs/latest_task_executor.js b/frontend/javascripts/libs/latest_task_executor.js new file mode 100644 index 00000000000..ec07677b2ef --- /dev/null +++ b/frontend/javascripts/libs/latest_task_executor.js @@ -0,0 +1,62 @@ +// @flow + +import Deferred from "libs/deferred"; + +type Task = () => Promise; +export const SKIPPED_TASK_REASON = "Skipped task"; + +/* + * The LatestTaskExecutor class allows to schedule tasks + * (see above type definition), which will be executed + * sequentially. However, the crux is that unstarted tasks + * are discarded if there is a newer task available. + * Existing tasks are not cancelled, which is why + * there can be at most two running tasks per + * LatestTaskExecutor instance. + * + * See the corresponding spec for examples. + */ + +export default class LatestTaskExecutor { + taskQueue: Array<{ task: Task, deferred: Deferred }> = []; + + schedule(task: Task): Promise { + return new Promise(resolve => { + const deferred = new Deferred(); + this.taskQueue.push({ task, deferred }); + + if (this.taskQueue.length === 1) { + this.__executeLatestPromise(); + } else { + // The promise will be scheduled as soon as the next promise + // from the queue is fulfilled. + } + return resolve(deferred.promise()); + }); + } + + __executeLatestPromise(): void { + if (this.taskQueue.length === 0) { + return; + } + + const latestTask = this.taskQueue.pop(); + const { task, deferred } = latestTask; + + // Discard the remaining queue + this.taskQueue.forEach(queueObject => { + // All other tasks are not executed, since + // they've already become obsolete. + queueObject.deferred.reject(new Error(SKIPPED_TASK_REASON)); + }); + this.taskQueue = [latestTask]; + + // Start the latest task + const promise = task(); + promise.then(result => { + this.taskQueue.shift(); + deferred.resolve(result); + this.__executeLatestPromise(); + }); + } +} diff --git a/frontend/javascripts/libs/utils.js b/frontend/javascripts/libs/utils.js index c4a3f453740..de3d465cac7 100644 --- a/frontend/javascripts/libs/utils.js +++ b/frontend/javascripts/libs/utils.js @@ -11,6 +11,12 @@ import window, { document, location } from "libs/window"; export type Comparator = (T, T) => -1 | 0 | 1; type UrlParams = { [key: string]: string }; +// Fix JS modulo bug +// http://javascript.about.com/od/problemsolving/a/modulobug.htm +export function mod(x: number, n: number) { + return ((x % n) + n) % n; +} + export function map2(fn: (A, number) => B, tuple: [A, A]): [B, B] { const [x, y] = tuple; return [fn(x, 0), fn(y, 1)]; @@ -315,6 +321,21 @@ export function sleep(timeout: number): Promise { }); } +// Only use this function if you really need a busy wait (useful +// for testing performance-related edge cases). Prefer `sleep` +// otherwise. +export function busyWaitDevHelper(time: number) { + const start = new Date(); + let now; + + while (true) { + now = new Date(); + if (now - start >= time) { + break; + } + } +} + export function animationFrame(): Promise { return new Promise(resolve => { window.requestAnimationFrame(resolve); @@ -421,12 +442,36 @@ export function isNoElementFocussed(): boolean { return document.activeElement === document.body; } +// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support +const areEventListenerOptionsSupported = _.once(() => { + let passiveSupported = false; + + try { + const options = { + // $FlowIgnore + get passive() { + // This function will be called when the browser + // attempts to access the passive property. + passiveSupported = true; + return true; + }, + }; + + window.addEventListener("test", options, options); + window.removeEventListener("test", options, options); + } catch (err) { + passiveSupported = false; + } + return passiveSupported; +}); + // https://stackoverflow.com/questions/25248286/native-js-equivalent-to-jquery-delegation# export function addEventListenerWithDelegation( element: HTMLElement, eventName: string, delegateSelector: string, handlerFunc: Function, + options: Object = {}, ) { const wrapperFunc = function(event: Event) { // $FlowFixMe Flow doesn't know native InputEvents @@ -438,7 +483,11 @@ export function addEventListenerWithDelegation( } } }; - element.addEventListener(eventName, wrapperFunc, false); + element.addEventListener( + eventName, + wrapperFunc, + areEventListenerOptionsSupported() ? options : false, + ); return { [eventName]: wrapperFunc }; } diff --git a/frontend/javascripts/messages.js b/frontend/javascripts/messages.js index 25bfa04ba33..2f0c5fff98a 100644 --- a/frontend/javascripts/messages.js +++ b/frontend/javascripts/messages.js @@ -34,13 +34,14 @@ export const settings = { (will take longer until you see data) or alternatively, improving the quality progressively (data will be loaded faster, but it will take more time until the best quality is shown).`, + mergerMode: "Enable Merger Mode", }; export default { yes: "Yes", no: "No", unknown_error: - "An unknown error occured. Please try again or check the console for more details.", + "An unknown error occurred. Please try again or check the console for more details.", "datastore.health": _.template( "The datastore server at <%- url %> does not seem too be available. Please check back in five minutes.", ), @@ -91,7 +92,7 @@ In order to restore the current window, a reload is necessary.`, "tracing.delete_tree": "Do you really want to delete the whole tree?", "tracing.delete_tree_with_initial_node": "This tree contains the initial node. Do you really want to delete the whole tree?", - "tracing.delete_mulitple_trees": _.template( + "tracing.delete_multiple_trees": _.template( "You have <%- countOfTrees %> trees selected, do you really want to delete all those trees?", ), "tracing.group_deletion_message": "Do you want to delete the selected group?", @@ -168,9 +169,9 @@ In order to restore the current window, a reload is necessary.`, "Do you really want to add one additional instance to all tasks of this project?", "project.none_selected": "No currently selected project found.", "project.successful_active_tasks_transfer": - "All active tasks were transfered to the selected user", + "All active tasks were transferred to the selected user", "project.unsuccessful_active_tasks_transfer": - "An error occured while trying to transfer the tasks. Please check your permissions and the server logs", + "An error occurred while trying to transfer the tasks. Please check your permissions and the server logs", "script.delete": "Do you really want to delete this script?", "team.delete": "Do you really want to delete this team?", "taskType.delete": "Do you really want to delete this task type and all its associated tasks?", @@ -178,7 +179,7 @@ In order to restore the current window, a reload is necessary.`, "auth.registration_email_invalid": "The input is not valid E-mail!", "auth.registration_password_input": "Please input your password!", "auth.registration_password_confirm": "Please confirm your password!", - "auth.registration_password_missmatch": "Passwords do not match!", + "auth.registration_password_mismatch": "Passwords do not match!", "auth.registration_password_length": "Passwords needs min. 8 characters.", "auth.registration_firstName_input": "Please input your first name!", "auth.registration_lastName_input": "Please input your last name!", diff --git a/frontend/javascripts/navbar.js b/frontend/javascripts/navbar.js index b1d8dd1d804..7e7a880a9d4 100644 --- a/frontend/javascripts/navbar.js +++ b/frontend/javascripts/navbar.js @@ -144,7 +144,6 @@ function StatisticsSubMenu({ collapse, ...menuProps }) { function HelpSubMenu({ isAdmin, version, collapse, ...other }) { return ( , + , ); // Don't highlight active menu items, when showing the narrow version of the navbar, diff --git a/frontend/javascripts/oxalis/api/api_latest.js b/frontend/javascripts/oxalis/api/api_latest.js index 3d96cbc7b53..26527d82cef 100644 --- a/frontend/javascripts/oxalis/api/api_latest.js +++ b/frontend/javascripts/oxalis/api/api_latest.js @@ -254,7 +254,7 @@ class TracingApi { * api.tracing.setActiveTree(3); */ setActiveTree(treeId: number) { - const tracing = Store.getState().tracing; + const { tracing } = Store.getState(); assertSkeleton(tracing); Store.dispatch(setActiveTreeAction(treeId)); } @@ -267,7 +267,7 @@ class TracingApi { * api.tracing.setTreeColorIndex(3, 10); */ setTreeColorIndex(treeId: ?number, colorIndex: number) { - const tracing = Store.getState().tracing; + const { tracing } = Store.getState(); assertSkeleton(tracing); Store.dispatch(setTreeColorIndexAction(treeId, colorIndex)); } @@ -525,7 +525,7 @@ class TracingApi { rotation?: Vector3, ): void { // Let the user still manipulate the "third dimension" during animation - const activeViewport = Store.getState().viewModeData.plane.activeViewport; + const { activeViewport } = Store.getState().viewModeData.plane; const dimensionToSkip = skipDimensions && activeViewport !== OrthoViews.TDView ? dimensions.thirdDimensionForPlane(activeViewport) @@ -665,11 +665,12 @@ class DataApi { * * @example * const position = [123, 123, 123]; - * const segmentId = await api.data.getDataValue("segmentation", position); + * const segmentationLayerName = "segmentation"; + * const segmentId = await api.data.getDataValue(segmentationLayerName, position); * const treeId = api.tracing.getActiveTreeId(); * const mapping = {[segmentId]: treeId} * - * api.setMapping("segmentation", mapping); + * api.setMapping(segmentationLayerName, mapping); */ setMapping( layerName: string, @@ -780,7 +781,7 @@ class DataApi { * api.data.downloadRawDataCuboid("segmentation", [0,0,0], [100,200,100]); */ downloadRawDataCuboid(layerName: string, topLeft: Vector3, bottomRight: Vector3): Promise { - const dataset = Store.getState().dataset; + const { dataset } = Store.getState(); return doWithToken(token => { const downloadUrl = @@ -967,6 +968,7 @@ class UtilsApi { * - SHUFFLE_ALL_TREE_COLORS * - CREATE_COMMENT * - DELETE_COMMENT + * @returns {function()} - A function used to unregister the overwriteFunction * * * @example @@ -978,9 +980,13 @@ class UtilsApi { */ registerOverwrite( actionName: string, - overwriteFunction: (store: S, next: (action: A) => void, originalAction: A) => void, + overwriteFunction: ( + store: S, + next: (action: A) => void, + originalAction: A, + ) => void | Promise, ) { - overwriteAction(actionName, overwriteFunction); + return overwriteAction(actionName, overwriteFunction); } /** diff --git a/frontend/javascripts/oxalis/merger_mode.js b/frontend/javascripts/oxalis/merger_mode.js new file mode 100644 index 00000000000..59feb82f9a2 --- /dev/null +++ b/frontend/javascripts/oxalis/merger_mode.js @@ -0,0 +1,294 @@ +// @flow +import { Modal } from "antd"; +import type { Node, TreeMap } from "oxalis/store"; +import api from "oxalis/api/internal_api"; + +type NodeWithTreeId = Node & { treeId: number }; + +type MergerModeState = { + treeColors: Object, + colorMapping: Object, + nodesPerSegment: Object, + nodes: Array, + segmentationLayerName: string, + nodeSegmentMap: Object, + segmentationOpacity: number, + segmentationOn: boolean, +}; + +const unregisterKeyHandlers = []; +const unregisterOverwrites = []; +let isCodeActive = false; + +function mapSegmentColorToTree(segId: number, treeId: number, mergerModeState: MergerModeState) { + // add segment to color mapping + const color = getTreeColor(treeId, mergerModeState); + mergerModeState.colorMapping[segId] = color; +} + +function getTreeColor(treeId: number, mergerModeState: MergerModeState) { + const { treeColors } = mergerModeState; + let color = treeColors[treeId]; + // Generate a new color if tree was never seen before + if (color === undefined) { + color = Math.ceil(127 * Math.random()); + treeColors[treeId] = color; + } + return color; +} + +function deleteColorMappingOfSegment(segId: number, mergerModeState: MergerModeState) { + // Remove segment from color mapping + delete mergerModeState.colorMapping[segId]; +} + +/* This function is used to increment the reference count / + number of nodes mapped to the given segment */ +function increaseNodesOfSegment(segementId: number, mergerModeState: MergerModeState) { + const { nodesPerSegment } = mergerModeState; + const currentValue = nodesPerSegment[segementId]; + if (currentValue == null) { + nodesPerSegment[segementId] = 1; + } else { + nodesPerSegment[segementId] = currentValue + 1; + } + return nodesPerSegment[segementId]; +} + +/* This function is used to decrement the reference count / + number of nodes mapped to the given segment. */ +function decreaseNodesOfSegment(segementId: number, mergerModeState: MergerModeState): number { + const { nodesPerSegment } = mergerModeState; + const currentValue = nodesPerSegment[segementId]; + nodesPerSegment[segementId] = currentValue - 1; + return nodesPerSegment[segementId]; +} + +function getAllNodesWithTreeId(): Array { + const trees: TreeMap = api.tracing.getAllTrees(); + const nodes = []; + // Create an array of all nodes, but with the additional treeId Property + Object.keys(trees).forEach(treeId => { + const currentTreeId = parseInt(treeId); + const currentTree = trees[currentTreeId]; + for (const node of currentTree.nodes.values()) { + const nodeWithTreeId: NodeWithTreeId = Object.assign({}, node, { + treeId: currentTreeId, + }); + nodes.push(nodeWithTreeId); + } + }); + return nodes; +} + +/* Here we intercept calls to the "addNode" method. This allows us to look up the segment id at the specified + point and display it in the same color as the rest of the aggregate. */ +async function createNodeOverwrite(store, call, action, mergerModeState: MergerModeState) { + call(action); + const { colorMapping, segmentationLayerName, nodeSegmentMap } = mergerModeState; + const pos = action.position; + const segmentId = await api.data.getDataValue(segmentationLayerName, pos); + + const activeTreeId = api.tracing.getActiveTreeId(); + const activeNodeId = api.tracing.getActiveNodeId(); + // If the node wasn't created. This should never happen. + if (activeTreeId == null || activeNodeId == null) { + Modal.info({ title: "The created node could not be detected." }); + return; + } + // If there is no segment id, the node was set too close to a border between segments. + if (!segmentId) { + Modal.info({ title: "You've set a point too close to grey. The node will be removed now." }); + api.tracing.deleteNode(activeNodeId, activeTreeId); + return; + } + + // Set segment id + nodeSegmentMap[activeNodeId] = segmentId; + // Count references + increaseNodesOfSegment(segmentId, mergerModeState); + mapSegmentColorToTree(segmentId, activeTreeId, mergerModeState); + + // Update mapping + api.data.setMapping(segmentationLayerName, colorMapping); +} + +/* This function decreases the number of nodes associated with the segment the passed node belongs to. + If the count reaches 0, the segment is removed from the mapping and this function returns true. + Otherwise the return value will be false. */ +function onNodeDeleted(mergerModeState: MergerModeState, nodeId: number) { + const segmentId = mergerModeState.nodeSegmentMap[nodeId]; + const numberOfNodesMappedToSegment = decreaseNodesOfSegment(segmentId, mergerModeState); + + if (numberOfNodesMappedToSegment === 0) { + // Reset color of all segments that were mapped to this tree + deleteColorMappingOfSegment(segmentId, mergerModeState); + return true; + } + return false; +} + +/* Overwrite the "deleteActiveNode" method in such a way that a segment changes back its color as soon as all + nodes are deleted from it. */ +function deleteActiveNodeOverwrite(store, call, action, mergerModeState: MergerModeState) { + const activeNodeId = api.tracing.getActiveNodeId(); + if (activeNodeId == null) { + return; + } + const noNodesLeftForTheSegment = onNodeDeleted(mergerModeState, activeNodeId); + if (noNodesLeftForTheSegment) { + api.data.setMapping(mergerModeState.segmentationLayerName, mergerModeState.colorMapping); + } + call(action); +} + +/* Overwrite the "deleteActiveTree" method in such a way that all segment changes back its color as soon as all + nodes are deleted from it. */ +function deleteActiveTreeOverwrite(store, call, action, mergerModeState: MergerModeState) { + const activeTreeId = api.tracing.getActiveTreeId(); + if (activeTreeId == null) { + return; + } + const deletedTree = api.tracing.getAllTrees()[activeTreeId]; + let didMappingChange = false; + for (const nodeId of deletedTree.nodes.keys()) { + didMappingChange = onNodeDeleted(mergerModeState, nodeId) || didMappingChange; + } + if (didMappingChange) { + api.data.setMapping(mergerModeState.segmentationLayerName, mergerModeState.colorMapping); + } + call(action); +} + +// Changes the opacity of the segmentation layer +function changeOpacity(mergerModeState: MergerModeState) { + if (mergerModeState.segmentationOn) { + api.data.setConfiguration("segmentationOpacity", 0); + mergerModeState.segmentationOn = false; + } else { + api.data.setConfiguration("segmentationOpacity", mergerModeState.segmentationOpacity); + mergerModeState.segmentationOn = true; + } +} + +function shuffleColorOfCurrentTree(mergerModeState: MergerModeState) { + const { treeColors, colorMapping, segmentationLayerName } = mergerModeState; + const setNewColorOfCurrentActiveTree = () => { + const activeTreeId = api.tracing.getActiveTreeId(); + if (activeTreeId == null) { + Modal.info({ title: "Could not find an active tree." }); + return; + } + const oldColor = getTreeColor(activeTreeId, mergerModeState); + // Reset the color of the active tree + treeColors[activeTreeId] = undefined; + // Applies the change of the color to all connected segments + Object.keys(colorMapping).forEach(key => { + if (colorMapping[key] === oldColor) { + colorMapping[key] = getTreeColor(activeTreeId, mergerModeState); + } + }); + // Update the segmentation + api.data.setMapping(segmentationLayerName, colorMapping); + }; + + Modal.confirm({ + title: "Do you want to set a new Color?", + onOk: setNewColorOfCurrentActiveTree, + onCancel() {}, + }); +} + +async function mergeSegmentsOfAlreadyExistingTrees(index = 0, mergerModeState: MergerModeState) { + const { nodes, segmentationLayerName, nodeSegmentMap, colorMapping } = mergerModeState; + const numbOfNodes = nodes.length; + if (index >= numbOfNodes) { + return; + } + + const [segMinVec, segMaxVec] = api.data.getBoundingBox(segmentationLayerName); + + const setSegementationOfNode = async node => { + const pos = node.position; + const { treeId } = node; + // Skip nodes outside segmentation + if ( + pos[0] < segMinVec[0] || + pos[1] < segMinVec[1] || + pos[2] < segMinVec[2] || + pos[0] >= segMaxVec[0] || + pos[1] >= segMaxVec[1] || + pos[2] >= segMaxVec[2] + ) { + // The node is not in bounds of the segmentation + return; + } + const segmentId = await api.data.getDataValue(segmentationLayerName, pos); + if (segmentId != null && segmentId > 0) { + // Store the segment id + nodeSegmentMap[node.id] = segmentId; + // Add to agglomerate + increaseNodesOfSegment(segmentId, mergerModeState); + mapSegmentColorToTree(segmentId, treeId, mergerModeState); + } + }; + const nodesMappedPromises = nodes.map(node => setSegementationOfNode(node)); + await Promise.all(nodesMappedPromises); + api.data.setMapping(segmentationLayerName, colorMapping); +} + +export async function enableMergerMode() { + if (isCodeActive) { + return; + } + isCodeActive = true; + // Create an object that store the state of the merger mode. + const mergerModeState: MergerModeState = { + treeColors: {}, + colorMapping: {}, + nodesPerSegment: {}, + nodes: getAllNodesWithTreeId(), + segmentationLayerName: api.data.getVolumeTracingLayerName(), + nodeSegmentMap: {}, + segmentationOpacity: ((api.data.getConfiguration("segmentationOpacity"): any): number), + segmentationOn: true, + }; + // Register the overwrites + unregisterOverwrites.push( + api.utils.registerOverwrite("CREATE_NODE", (store, next, originalAction) => + createNodeOverwrite(store, next, originalAction, mergerModeState), + ), + ); + unregisterOverwrites.push( + api.utils.registerOverwrite("DELETE_NODE", (store, next, originalAction) => + deleteActiveNodeOverwrite(store, next, originalAction, mergerModeState), + ), + ); + unregisterOverwrites.push( + api.utils.registerOverwrite("DELETE_TREE", (store, next, originalAction) => + deleteActiveTreeOverwrite(store, next, originalAction, mergerModeState), + ), + ); + // Register the additional key handlers + unregisterKeyHandlers.push( + api.utils.registerKeyHandler("8", () => { + shuffleColorOfCurrentTree(mergerModeState); + }), + ); + unregisterKeyHandlers.push( + api.utils.registerKeyHandler("9", () => { + changeOpacity(mergerModeState); + }), + ); + // wait for preprocessing the already existing trees before returning + await mergeSegmentsOfAlreadyExistingTrees(0, mergerModeState); +} + +export function disableMergerMode() { + if (!isCodeActive) { + return; + } + isCodeActive = false; + unregisterOverwrites.forEach(unregisterFunction => unregisterFunction()); + unregisterKeyHandlers.forEach(unregisterObject => unregisterObject.unregister()); +} diff --git a/frontend/javascripts/oxalis/model/accessors/flycam_accessor.js b/frontend/javascripts/oxalis/model/accessors/flycam_accessor.js index 0ba10a2e1ba..ef6aa64cf96 100644 --- a/frontend/javascripts/oxalis/model/accessors/flycam_accessor.js +++ b/frontend/javascripts/oxalis/model/accessors/flycam_accessor.js @@ -6,7 +6,7 @@ import memoizeOne from "memoize-one"; import type { Flycam, LoadingStrategy, OxalisState } from "oxalis/store"; import { M4x4, type Matrix4x4 } from "libs/mjs"; import { ZOOM_STEP_INTERVAL } from "oxalis/model/reducers/flycam_reducer"; -import { clamp, map3 } from "libs/utils"; +import { clamp, map3, mod } from "libs/utils"; import { getInputCatcherRect, getViewportRects } from "oxalis/model/accessors/view_mode_accessor"; import { getMaxZoomStep, getResolutions } from "oxalis/model/accessors/dataset_accessor"; import Dimensions from "oxalis/model/dimensions"; @@ -44,8 +44,6 @@ function calculateTotalBucketCountForZoomLevel( const sphericalCapRadius = constants.DEFAULT_SPHERICAL_CAP_RADIUS; const areas = getAreas(viewportRects, position, zoomFactor, datasetScale); - const fallbackZoomStep = logZoomStep + 1; - const isFallbackAvailable = fallbackZoomStep < resolutions.length; const dummyMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const matrix = M4x4.scale1(zoomFactor, dummyMatrix); @@ -56,8 +54,6 @@ function calculateTotalBucketCountForZoomLevel( enqueueFunction, matrix, logZoomStep, - fallbackZoomStep, - isFallbackAvailable, abortLimit, ); } else if (viewMode === constants.MODE_ARBITRARY) { @@ -68,8 +64,6 @@ function calculateTotalBucketCountForZoomLevel( enqueueFunction, matrix, logZoomStep, - fallbackZoomStep, - isFallbackAvailable, abortLimit, ); } else { @@ -189,10 +183,6 @@ export function getRotation(flycam: Flycam): Vector3 { const matrix = new THREE.Matrix4().fromArray(flycam.currentMatrix).transpose(); object.applyMatrix(matrix); - // Fix JS modulo bug - // http://javascript.about.com/od/problemsolving/a/modulobug.htm - const mod = (x, n) => ((x % n) + n) % n; - const rotation: Vector3 = [object.rotation.x, object.rotation.y, object.rotation.z - Math.PI]; return [ mod((180 / Math.PI) * rotation[0], 360), diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js index 8b6b61a83e8..0219a827263 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.js @@ -103,6 +103,7 @@ type DeleteCommentAction = { type: "DELETE_COMMENT", nodeId: ?number, treeId?: n type SetTracingAction = { type: "SET_TRACING", tracing: SkeletonTracing }; type SetTreeGroupsAction = { type: "SET_TREE_GROUPS", treeGroups: Array }; type SetTreeGroupAction = { type: "SET_TREE_GROUP", groupId: ?number, treeId?: number }; +type SetMergerModeEnabledAction = { type: "SET_MERGER_MODE_ENABLED", active: boolean }; type NoAction = { type: "NONE" }; export type SkeletonTracingAction = @@ -138,7 +139,8 @@ export type SkeletonTracingAction = | NoAction | SetTracingAction | SetTreeGroupsAction - | SetTreeGroupAction; + | SetTreeGroupAction + | SetMergerModeEnabledAction; export const SkeletonTracingSaveRelevantActions = [ "INITIALIZE_SKELETONTRACING", @@ -163,6 +165,7 @@ export const SkeletonTracingSaveRelevantActions = [ "SET_USER_BOUNDING_BOX", "SET_TREE_GROUPS", "SET_TREE_GROUP", + "SET_MERGER_MODE_ENABLED", ]; const noAction = (): NoAction => ({ @@ -398,6 +401,10 @@ export const setTreeGroupAction = (groupId: ?number, treeId?: number): SetTreeGr treeId, }); +export const setMergerModeEnabledAction = (active: boolean): SetMergerModeEnabledAction => ({ + type: "SET_MERGER_MODE_ENABLED", + active, +}); // The following actions have the prefix "AsUser" which means that they // offer some additional logic which is sensible from a user-centered point of view. // For example, the deleteActiveNodeAsUserAction also initiates the deletion of a tree, diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/flight_bucket_picker.js b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/flight_bucket_picker.js index 80980eb5e59..72a355e1187 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/flight_bucket_picker.js +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/flight_bucket_picker.js @@ -42,14 +42,14 @@ export default function determineBucketsForFlight( enqueueFunction: EnqueueFunction, matrix: Matrix4x4, logZoomStep: number, - fallbackZoomStep: number, - isFallbackAvailable: boolean, abortLimit?: number, ): void { const queryMatrix = M4x4.scale1(1, matrix); const width = constants.VIEWPORT_WIDTH; const halfWidth = width / 2; const cameraVertex = [0, 0, -sphericalCapRadius]; + const fallbackZoomStep = logZoomStep + 1; + const isFallbackAvailable = fallbackZoomStep < resolutions.length; const transformToSphereCap = _vec => { const vec = V3.sub(_vec, cameraVertex); @@ -67,7 +67,7 @@ export default function determineBucketsForFlight( const cameraDirection = V3.sub(centerPosition, cameraPosition); V3.scale(cameraDirection, 1 / Math.abs(V3.length(cameraDirection)), cameraDirection); - const iterStep = 10; + const iterStep = 8; for (let y = -halfWidth; y <= halfWidth; y += iterStep) { const xOffset = y % iterStep; for (let x = -halfWidth - xOffset; x <= halfWidth + xOffset; x += iterStep) { diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/oblique_bucket_picker.js b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/oblique_bucket_picker.js index 7df64e31c70..e8d91dcd480 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/oblique_bucket_picker.js +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket_picker_strategies/oblique_bucket_picker.js @@ -35,19 +35,19 @@ export default function determineBucketsForOblique( enqueueFunction: EnqueueFunction, matrix: Matrix4x4, logZoomStep: number, - fallbackZoomStep: number, - isFallbackAvailable: boolean, abortLimit?: number, ): void { const uniqueBucketMap = new ThreeDMap(); let currentCount = 0; const queryMatrix = M4x4.scale1(1, matrix); + const fallbackZoomStep = logZoomStep + 1; + const isFallbackAvailable = fallbackZoomStep < resolutions.length; // Buckets adjacent to the current viewport are also loaded so that these // buckets are already on the GPU when the user moves a little. const enlargementFactor = 1.1; const enlargedExtent = constants.VIEWPORT_WIDTH * enlargementFactor; - const steps = 25; + const steps = 30; const stepSize = enlargedExtent / steps; const enlargedHalfExtent = enlargedExtent / 2; diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.js b/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.js index d56fe505ccb..e3e65a65434 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.js +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.js @@ -1,8 +1,8 @@ // @flow -import PriorityQueue from "js-priority-queue"; import * as THREE from "three"; import _ from "lodash"; +import memoizeOne from "memoize-one"; import { type Area, @@ -11,8 +11,12 @@ import { } from "oxalis/model/accessors/flycam_accessor"; import { DataBucket } from "oxalis/model/bucket_data_handling/bucket"; import { M4x4 } from "libs/mjs"; +import { createWorker } from "oxalis/workers/comlink_wrapper"; +import { getAnchorPositionToCenterDistance } from "oxalis/model/bucket_data_handling/bucket_picker_strategies/orthogonal_bucket_picker"; import { getResolutions, getByteCount } from "oxalis/model/accessors/dataset_accessor"; +import AsyncBucketPickerWorker from "oxalis/workers/async_bucket_picker.worker"; import type DataCube from "oxalis/model/bucket_data_handling/data_cube"; +import LatestTaskExecutor, { SKIPPED_TASK_REASON } from "libs/latest_task_executor"; import type PullQueue from "oxalis/model/bucket_data_handling/pullqueue"; import Store from "oxalis/store"; import TextureBucketManager from "oxalis/model/bucket_data_handling/texture_bucket_manager"; @@ -24,13 +28,13 @@ import constants, { type Vector4, addressSpaceDimensions, } from "oxalis/constants"; -import determineBucketsForFlight from "oxalis/model/bucket_data_handling/bucket_picker_strategies/flight_bucket_picker"; -import determineBucketsForOblique from "oxalis/model/bucket_data_handling/bucket_picker_strategies/oblique_bucket_picker"; -import determineBucketsForOrthogonal, { - getAnchorPositionToCenterDistance, -} from "oxalis/model/bucket_data_handling/bucket_picker_strategies/orthogonal_bucket_picker"; import shaderEditor from "oxalis/model/helpers/shader_editor"; +const asyncBucketPick = memoizeOne(createWorker(AsyncBucketPickerWorker), (oldArgs, newArgs) => + _.isEqual(oldArgs, newArgs), +); +const dummyBuffer = new ArrayBuffer(0); + export type EnqueueFunction = (Vector4, number) => void; // each index of the returned Vector3 is either -1 or +1. @@ -47,24 +51,41 @@ function getSubBucketLocality(position: Vector3, resolution: Vector3): Vector3 { return position.map((pos, idx) => roundToNearestBucketBoundary(position, idx)); } -function consumeBucketsFromPriorityQueue( - queue: PriorityQueue<{ bucketAddress: Vector4, priority: number }>, +function consumeBucketsFromArrayBuffer( + buffer: ArrayBuffer, cube: DataCube, capacity: number, ): Array<{ priority: number, bucket: DataBucket }> { const bucketsWithPriorities = []; + const uint32Array = new Uint32Array(buffer); + + let currentElementIndex = 0; + const intsPerItem = 5; // [x, y, z, zoomStep, priority] + // Consume priority queue until we maxed out the capacity while (bucketsWithPriorities.length < capacity) { - if (queue.length === 0) { + const currentBufferIndex = currentElementIndex * intsPerItem; + if (currentBufferIndex >= uint32Array.length) { break; } - const { bucketAddress, priority } = queue.dequeue(); + + const bucketAddress = [ + uint32Array[currentBufferIndex], + uint32Array[currentBufferIndex + 1], + uint32Array[currentBufferIndex + 2], + uint32Array[currentBufferIndex + 3], + ]; + const priority = uint32Array[currentBufferIndex + 4]; + const bucket = cube.getOrCreateBucket(bucketAddress); if (bucket.type !== "null") { bucketsWithPriorities.push({ bucket, priority }); } + + currentElementIndex++; } + return bucketsWithPriorities; } @@ -88,6 +109,7 @@ export default class LayerRenderingManager { isSegmentation: boolean; needsRefresh: boolean = false; currentBucketPickerTick: number = 0; + latestTaskExecutor: LatestTaskExecutor = new LatestTaskExecutor(); constructor( name: string, @@ -136,8 +158,6 @@ export default class LayerRenderingManager { const state = Store.getState(); const { dataset, datasetConfiguration } = state; const isAnchorPointNew = this.maybeUpdateAnchorPoint(position, logZoomStep); - const fallbackZoomStep = logZoomStep + 1; - const isFallbackAvailable = fallbackZoomStep <= this.cube.MAX_ZOOM_STEP; if (logZoomStep > this.cube.MAX_ZOOM_STEP) { // Don't render anything if the zoomStep is too high @@ -145,7 +165,8 @@ export default class LayerRenderingManager { return this.cachedAnchorPoint; } - const subBucketLocality = getSubBucketLocality(position, getResolutions(dataset)[logZoomStep]); + const resolutions = getResolutions(dataset); + const subBucketLocality = getSubBucketLocality(position, resolutions[logZoomStep]); const areas = getAreasFromState(state); const matrix = getZoomedMatrix(state.flycam); @@ -153,7 +174,8 @@ export default class LayerRenderingManager { const { viewMode } = state.temporaryConfiguration; const isArbitrary = constants.MODES_ARBITRARY.includes(viewMode); const { sphericalCapRadius } = state.userConfiguration; - const isInvisible = this.isSegmentation && datasetConfiguration.segmentationOpacity === 0; + const isInvisible = + this.isSegmentation && (datasetConfiguration.segmentationOpacity === 0 || isArbitrary); if ( isAnchorPointNew || !_.isEqual(areas, this.lastAreas) || @@ -174,71 +196,57 @@ export default class LayerRenderingManager { this.currentBucketPickerTick++; this.pullQueue.clear(); - const bucketQueue = new PriorityQueue({ - // small priorities take precedence - comparator: (b, a) => b.priority - a.priority, - }); - const enqueueFunction = (bucketAddress, priority) => { - bucketQueue.queue({ bucketAddress, priority }); - }; + let pickingPromise: Promise = Promise.resolve(dummyBuffer); if (!isInvisible) { - const resolutions = getResolutions(dataset); - if (viewMode === constants.MODE_ARBITRARY_PLANE) { - determineBucketsForOblique( - resolutions, - position, - enqueueFunction, - matrix, - logZoomStep, - fallbackZoomStep, - isFallbackAvailable, - ); - } else if (viewMode === constants.MODE_ARBITRARY) { - determineBucketsForFlight( + pickingPromise = this.latestTaskExecutor.schedule(() => + asyncBucketPick( + viewMode, resolutions, position, sphericalCapRadius, - enqueueFunction, matrix, logZoomStep, - fallbackZoomStep, - isFallbackAvailable, - ); - } else { - determineBucketsForOrthogonal( - resolutions, - enqueueFunction, datasetConfiguration.loadingStrategy, - logZoomStep, this.cachedAnchorPoint, areas, subBucketLocality, - ); - } + ), + ); } - const bucketsWithPriorities = consumeBucketsFromPriorityQueue( - bucketQueue, - this.cube, - this.textureBucketManager.maximumCapacity, - ); - - const buckets = bucketsWithPriorities.map(({ bucket }) => bucket); - this.cube.markBucketsAsUnneeded(); - // This tells the bucket collection, that the buckets are necessary for rendering - buckets.forEach(b => b.markAsNeeded()); - - this.textureBucketManager.setActiveBuckets(buckets, this.cachedAnchorPoint); + this.textureBucketManager.setAnchorPoint(this.cachedAnchorPoint); - // In general, pull buckets which are not available but should be sent to the GPU - const missingBuckets = bucketsWithPriorities - .filter(({ bucket }) => !bucket.hasData()) - .filter(({ bucket }) => bucket.zoomedAddress[3] <= this.cube.MAX_UNSAMPLED_ZOOM_STEP) - .map(({ bucket, priority }) => ({ bucket: bucket.zoomedAddress, priority })); + pickingPromise.then( + buffer => { + const bucketsWithPriorities = consumeBucketsFromArrayBuffer( + buffer, + this.cube, + this.textureBucketManager.maximumCapacity, + ); - this.pullQueue.addAll(missingBuckets); - this.pullQueue.pull(); + const buckets = bucketsWithPriorities.map(({ bucket }) => bucket); + this.cube.markBucketsAsUnneeded(); + // This tells the bucket collection, that the buckets are necessary for rendering + buckets.forEach(b => b.markAsNeeded()); + + this.textureBucketManager.setActiveBuckets(buckets, this.cachedAnchorPoint); + + // In general, pull buckets which are not available but should be sent to the GPU + const missingBuckets = bucketsWithPriorities + .filter(({ bucket }) => !bucket.hasData()) + .filter(({ bucket }) => bucket.zoomedAddress[3] <= this.cube.MAX_UNSAMPLED_ZOOM_STEP) + .map(({ bucket, priority }) => ({ bucket: bucket.zoomedAddress, priority })); + + this.pullQueue.addAll(missingBuckets); + this.pullQueue.pull(); + }, + reason => { + if (reason.message !== SKIPPED_TASK_REASON) { + throw reason; + } + }, + ); } return this.cachedAnchorPoint; diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/texture_bucket_manager.js b/frontend/javascripts/oxalis/model/bucket_data_handling/texture_bucket_manager.js index d5d7b280eb7..d51f37e349c 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/texture_bucket_manager.js +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/texture_bucket_manager.js @@ -4,8 +4,10 @@ import _ from "lodash"; import { DataBucket, bucketDebuggingFlags } from "oxalis/model/bucket_data_handling/bucket"; import { createUpdatableTexture } from "oxalis/geometries/materials/plane_material_factory_helpers"; +import { getBaseBucketsForFallbackBucket } from "oxalis/model/helpers/position_converter"; import { getMaxZoomStepDiff } from "oxalis/model/bucket_data_handling/loading_strategy_logic"; import { getRenderer } from "oxalis/controller/renderer"; +import { getResolutions } from "oxalis/model/accessors/dataset_accessor"; import { waitForCondition } from "libs/utils"; import Store from "oxalis/store"; import UpdatableTexture from "libs/UpdatableTexture"; @@ -96,6 +98,11 @@ export default class TextureBucketManager { this.freeIndexSet.add(unusedIndex); } + setAnchorPoint(anchorPoint: Vector4): void { + this.currentAnchorPoint = anchorPoint; + this._refreshLookUpBuffer(); + } + // Takes an array of buckets (relative to an anchorPoint) and ensures that these // are written to the dataTexture. The lookUpTexture will be updated to reflect the // new buckets. @@ -260,15 +267,17 @@ export default class TextureBucketManager { } _refreshLookUpBuffer() { - /* This method completely completely re-writes the lookup buffer. This could be smarter, but it's - * probably not worth it. + /* This method completely completely re-writes the lookup buffer. * It works as follows: * - write -2 into the entire buffer as a fallback - * - iterate over all buckets which should be available to the GPU - * - only consider the buckets in the native zoomStep (=> zoomStep === 0) - * - if the current bucket was committed, write the address for that bucket into the look up buffer - * - otherwise, check whether the bucket's fallback bucket is committed so that this can be written into - * the look up buffer (repeat for the next fallback if the bucket wasn't committed). + * - iterate over all buckets + * - if the current bucket is in the current zoomStep ("isBaseBucket"), either + * - write the target address to the look up buffer if the bucket was committed + * - otherwise: write a fallback bucket to the look up buffer + * - else if the current bucket is a fallback bucket, write the address for that bucket into all + * the positions of the look up buffer which map to that fallback bucket (in an isotropic case, that's 8 + * positions). Only do this if the bucket belongs to the first fallback layer. Otherwise, the complexity + would be too high, due to the exponential combinations. */ this.lookUpBuffer.fill(-2); @@ -278,46 +287,65 @@ export default class TextureBucketManager { const currentZoomStep = this.currentAnchorPoint[3]; for (const [bucket, reservedAddress] of this.activeBucketToIndexMap.entries()) { - if (bucket.zoomedAddress[3] > currentZoomStep) { - // only write high-res buckets (if a bucket is missing, the fallback bucket will then be written - // into the look up buffer) - continue; - } - const lookUpIdx = this._getBucketIndex(bucket); - const posInBuffer = channelCountForLookupBuffer * lookUpIdx; - let address = -1; let bucketZoomStep = bucket.zoomedAddress[3]; if (!bucketDebuggingFlags.enforcedZoomDiff && this.committedBucketSet.has(bucket)) { address = reservedAddress; - } else { - let fallbackBucket = bucket.getFallbackBucket(); - let abortFallbackLoop = false; - const maxAllowedZoomStep = - currentZoomStep + (bucketDebuggingFlags.enforcedZoomDiff || maxZoomStepDiff); - - while (!abortFallbackLoop) { - if (fallbackBucket.type !== "null") { - if ( - fallbackBucket.zoomedAddress[3] <= maxAllowedZoomStep && - this.committedBucketSet.has(fallbackBucket) - ) { - address = this.activeBucketToIndexMap.get(fallbackBucket); - address = address != null ? address : -1; - bucketZoomStep = fallbackBucket.zoomedAddress[3]; - abortFallbackLoop = true; + } + + const isBaseBucket = bucketZoomStep === currentZoomStep; + const isFirstFallback = bucketZoomStep - 1 === currentZoomStep; + if (isBaseBucket) { + if (address === -1) { + let fallbackBucket = bucket.getFallbackBucket(); + let abortFallbackLoop = false; + const maxAllowedZoomStep = + currentZoomStep + (bucketDebuggingFlags.enforcedZoomDiff || maxZoomStepDiff); + while (!abortFallbackLoop) { + if (fallbackBucket.type !== "null") { + if ( + fallbackBucket.zoomedAddress[3] <= maxAllowedZoomStep && + this.committedBucketSet.has(fallbackBucket) + ) { + address = this.activeBucketToIndexMap.get(fallbackBucket); + address = address != null ? address : -1; + bucketZoomStep = fallbackBucket.zoomedAddress[3]; + abortFallbackLoop = true; + } else { + // Try next fallback bucket + fallbackBucket = fallbackBucket.getFallbackBucket(); + } } else { - // Try next fallback bucket - fallbackBucket = fallbackBucket.getFallbackBucket(); + abortFallbackLoop = true; } - } else { - abortFallbackLoop = true; } } - } - this.lookUpBuffer[posInBuffer] = address; - this.lookUpBuffer[posInBuffer + 1] = bucketZoomStep; + const lookUpIdx = this._getBucketIndex(bucket.zoomedAddress); + const posInBuffer = channelCountForLookupBuffer * lookUpIdx; + this.lookUpBuffer[posInBuffer] = address; + this.lookUpBuffer[posInBuffer + 1] = bucketZoomStep; + } else if (isFirstFallback) { + if (address !== -1) { + const baseBucketAddresses = this._getBaseBucketAddresses(bucket, 1); + for (const baseBucketAddress of baseBucketAddresses) { + const lookUpIdx = this._getBucketIndex(baseBucketAddress); + const posInBuffer = channelCountForLookupBuffer * lookUpIdx; + if (this.lookUpBuffer[posInBuffer] !== -2) { + // Another bucket was already placed here. Skip the entire loop + break; + } + this.lookUpBuffer[posInBuffer] = address; + this.lookUpBuffer[posInBuffer + 1] = bucketZoomStep; + } + } else { + // Don't overwrite the default -2 within the look up buffer for fallback buckets, + // since the effort is not worth it (only has an impact on the fallback color within the shader) + } + } else { + // Don't handle buckets with zoomStepDiff > 1, because filling the corresponding + // positions in the lookup buffer would take 8**zoomStepDiff iterations PER bucket. + } } this.lookUpTexture.update(this.lookUpBuffer, 0, 0, lookUpBufferWidth, lookUpBufferWidth); @@ -325,8 +353,7 @@ export default class TextureBucketManager { window.needsRerender = true; } - _getBucketIndex(bucket: DataBucket): number { - const bucketPosition = bucket.zoomedAddress; + _getBucketIndex(bucketPosition: Vector4): number { const anchorPoint = this.currentAnchorPoint; const x = bucketPosition[0] - anchorPoint[0]; @@ -346,4 +373,9 @@ export default class TextureBucketManager { x ); } + + _getBaseBucketAddresses(bucket: DataBucket, zoomStepDifference: number): Array { + const resolutions = getResolutions(Store.getState().dataset); + return getBaseBucketsForFallbackBucket(bucket.zoomedAddress, zoomStepDifference, resolutions); + } } diff --git a/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.js b/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.js index a1c46a1db6c..35ce359ddb0 100644 --- a/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.js +++ b/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.js @@ -7,7 +7,7 @@ const overwrites = {}; export function overwriteAction( actionName: string, - overwriteFunction: (store: S, next: (action: A) => void, action: A) => void, + overwriteFunction: (store: S, next: (action: A) => void, action: A) => void | Promise, ) { if (overwrites[actionName]) { console.warn( @@ -18,6 +18,9 @@ export function overwriteAction( } overwrites[actionName] = overwriteFunction; + return () => { + delete overwrites[actionName]; + }; } export function removeOverwrite(actionName: string) { diff --git a/frontend/javascripts/oxalis/model/helpers/position_converter.js b/frontend/javascripts/oxalis/model/helpers/position_converter.js index c06e229c560..2b2abfa9802 100644 --- a/frontend/javascripts/oxalis/model/helpers/position_converter.js +++ b/frontend/javascripts/oxalis/model/helpers/position_converter.js @@ -94,3 +94,45 @@ export function zoomedAddressToAnotherZoomStep( export function getBucketExtent(resolutions: Vector3[], resolutionIndex: number): Vector3 { return bucketPositionToGlobalAddress([1, 1, 1, resolutionIndex], resolutions); } + +// This function returns all bucket addresses for which the fallback bucket +// is the provided bucket. +export function getBaseBucketsForFallbackBucket( + fallbackBucketAddress: Vector4, + zoomStepDifference: number, + resolutions: Array, +): Array { + const fallbackBucketZoomStep = fallbackBucketAddress[3]; + const betterZoomStep = fallbackBucketZoomStep - zoomStepDifference; + const betterBucketAddress = zoomedAddressToAnotherZoomStep( + fallbackBucketAddress, + resolutions, + betterZoomStep, + ); + if (zoomStepDifference > 1) { + // Due to the exponential complexity of calculating the "better bucket addresses", + // we currently only support this function if zoomStepDifference === 1 + return [betterBucketAddress]; + } + + // resolutionFactors is a [x, y, z] tuple with x, y, z being 1 or 2 each (because + // zoomStepDifference === 1). In the case of isotropic resolutions, it's simply [2, 2, 2] + const resolutionFactors = getResolutionsFactors( + resolutions[fallbackBucketZoomStep], + resolutions[betterZoomStep], + ); + + const bucketAddresses = []; + + const [baseX, baseY, baseZ] = betterBucketAddress; + for (let _x = 0; _x < resolutionFactors[0]; _x++) { + for (let _y = 0; _y < resolutionFactors[1]; _y++) { + for (let _z = 0; _z < resolutionFactors[2]; _z++) { + const newAddress = [baseX + _x, baseY + _y, baseZ + _z, betterZoomStep]; + bucketAddresses.push(newAddress); + } + } + } + + return bucketAddresses; +} diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js index 198cc35b274..9a363909aaa 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.js @@ -641,6 +641,16 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState ) .getOrElse(state); } + case "SET_MERGER_MODE_ENABLED": { + const { active } = action; + return update(state, { + temporaryConfiguration: { + isMergerModeEnabled: { + $set: active, + }, + }, + }); + } default: return state; diff --git a/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.js b/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.js index d0c219fc018..58cfed9ed15 100644 --- a/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.js +++ b/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.js @@ -161,11 +161,9 @@ void main() { vec3 data_color = vec3(0.0); vec3 color_value = vec3(0.0); float fallbackZoomStep; - bool hasFallback; <% _.each(colorLayerNames, function(name, layerIndex){ %> fallbackZoomStep = min(<%= name %>_maxZoomStep, zoomStep + 1.0); - hasFallback = fallbackZoomStep > zoomStep; // Get grayscale value for <%= name %> color_value = getMaybeFilteredColorOrFallback( diff --git a/frontend/javascripts/oxalis/shaders/texture_access.glsl.js b/frontend/javascripts/oxalis/shaders/texture_access.glsl.js index 4f96df5cdf0..11a1057aea7 100644 --- a/frontend/javascripts/oxalis/shaders/texture_access.glsl.js +++ b/frontend/javascripts/oxalis/shaders/texture_access.glsl.js @@ -131,6 +131,23 @@ export const getColorForCoords: ShaderModule = { float bucketAddress = bucketAddressWithZoomStep.x; float renderedZoomStep = bucketAddressWithZoomStep.y; + if (bucketAddress == -2.0) { + // The bucket is out of bounds. Render black + // In flight mode, it can happen that buckets were not passed to the GPU + // since the approximate implementation of the bucket picker missed the bucket. + // We simply handle this case as if the bucket was not yet loaded which means + // that fallback data is loaded. + // The downside is that data which does not exist, will be rendered gray instead of black. + // Issue to track progress: #3446 + float alpha = isFlightMode() ? -1.0 : 0.0; + return vec4(0.0, 0.0, 0.0, alpha); + } + + if (bucketAddress < 0. || isNan(bucketAddress)) { + // Not-yet-existing data is encoded with a = -1.0 + return vec4(0.0, 0.0, 0.0, -1.0); + } + if (renderedZoomStep != zoomStep) { /* We already know which fallback bucket we have to look into. However, * for 8 mag-1 buckets, there is usually one fallback bucket in mag-2. @@ -157,23 +174,6 @@ export const getColorForCoords: ShaderModule = { ); } - if (bucketAddress == -2.0) { - // The bucket is out of bounds. Render black - // In flight mode, it can happen that buckets were not passed to the GPU - // since the approximate implementation of the bucket picker missed the bucket. - // We simply handle this case as if the bucket was not yet loaded which means - // that fallback data is loaded. - // The downside is that data which does not exist, will be rendered gray instead of black. - // Issue to track progress: #3446 - float alpha = isFlightMode() ? -1.0 : 0.0; - return vec4(0.0, 0.0, 0.0, alpha); - } - - if (bucketAddress < 0. || isNan(bucketAddress)) { - // Not-yet-existing data is encoded with a = -1.0 - return vec4(0.0, 0.0, 0.0, -1.0); - } - // bucketAddress can span multiple data textures. If the address is higher // than the capacity of one texture, we mod the value and use the div (floored division) as the // texture index diff --git a/frontend/javascripts/oxalis/store.js b/frontend/javascripts/oxalis/store.js index 55d39b2464a..63630fac03a 100644 --- a/frontend/javascripts/oxalis/store.js +++ b/frontend/javascripts/oxalis/store.js @@ -277,6 +277,7 @@ export type TemporaryConfiguration = { +isMappingEnabled: boolean, +mappingSize: number, }, + +isMergerModeEnabled: boolean, }; export type Script = APIScript; @@ -470,10 +471,12 @@ export const defaultState: OxalisState = { isMappingEnabled: false, mappingSize: 0, }, + isMergerModeEnabled: false, }, task: null, dataset: { name: "Test Dataset", + isUnreported: false, created: 123, dataSource: { dataLayers: [], diff --git a/frontend/javascripts/oxalis/view/action-bar/save_button.js b/frontend/javascripts/oxalis/view/action-bar/save_button.js index ede847d99f8..f6e74dadec7 100644 --- a/frontend/javascripts/oxalis/view/action-bar/save_button.js +++ b/frontend/javascripts/oxalis/view/action-bar/save_button.js @@ -25,6 +25,7 @@ type State = { const SAVE_POLLING_INTERVAL = 1000; class SaveButton extends React.PureComponent { + savedPollingInterval: number = 0; state = { isStateSaved: false, }; @@ -38,7 +39,6 @@ class SaveButton extends React.PureComponent { window.clearInterval(this.savedPollingInterval); } - savedPollingInterval: number = 0; _forceUpdate = () => { const isStateSaved = Model.stateSaved(); this.setState({ diff --git a/frontend/javascripts/oxalis/view/components/dom_visibility_observer.js b/frontend/javascripts/oxalis/view/components/dom_visibility_observer.js new file mode 100644 index 00000000000..e89e5c20183 --- /dev/null +++ b/frontend/javascripts/oxalis/view/components/dom_visibility_observer.js @@ -0,0 +1,58 @@ +// @flow +import * as React from "react"; + +// This component uses an IntersectionObserver to find out if the element with the id targetId +// is visible in the current viewport or not. It then calls its children render function with that value. +// This allows to not render performance-heavy components or to disable shortcuts if their golden layout tab is not visible. + +type Props = { + targetId: string, + children: (isVisibleInDom: boolean) => React.Node, +}; + +type State = { + isVisibleInDom: boolean, +}; + +export default class DomVisibilityObserver extends React.Component { + observer: ?IntersectionObserver; + target: ?HTMLElement; + timeoutId: ?TimeoutID; + + state = { + isVisibleInDom: true, + }; + + componentDidMount() { + // Not supported in Safari as of now (see https://caniuse.com/#search=intersectionobserver) + if ( + "IntersectionObserver" in window && + "IntersectionObserverEntry" in window && + "isIntersecting" in window.IntersectionObserverEntry.prototype + ) { + this.attachObserver(); + } + } + + componentWillUnmount() { + if (this.observer != null && this.target != null) this.observer.unobserve(this.target); + if (this.timeoutId != null) clearTimeout(this.timeoutId); + } + + attachObserver = () => { + const target = document.getElementById(this.props.targetId); + if (target != null) { + const callback = interactionEntries => + this.setState({ isVisibleInDom: interactionEntries[0].isIntersecting }); + this.observer = new IntersectionObserver(callback, {}); + this.observer.observe(target); + this.target = target; + } else { + this.timeoutId = setTimeout(this.attachObserver, 1000); + } + }; + + render() { + return this.props.children(this.state.isVisibleInDom); + } +} diff --git a/frontend/javascripts/oxalis/view/layouting/golden_layout_adapter.js b/frontend/javascripts/oxalis/view/layouting/golden_layout_adapter.js index 5419e9d8d6a..fb172bd2faa 100644 --- a/frontend/javascripts/oxalis/view/layouting/golden_layout_adapter.js +++ b/frontend/javascripts/oxalis/view/layouting/golden_layout_adapter.js @@ -10,6 +10,7 @@ import Constants from "oxalis/constants"; import Store from "oxalis/store"; import Toast from "libs/toast"; import window, { document } from "libs/window"; +import { InputKeyboardNoLoop } from "libs/input"; import { PortalTarget, RenderToPortal } from "./portal_utils"; import { layoutEmitter, getLayoutConfig } from "./layout_persistence"; @@ -69,8 +70,8 @@ const updateSizeForGl = gl => { export class GoldenLayoutAdapter extends React.PureComponent, *> { gl: GoldenLayout; - unbindListeners: Array<() => void>; - maximizedItem: null; + maximizedItem: ?Object = null; + unbindListeners: Array<() => void> = []; componentDidMount() { this.setupLayout(); @@ -91,6 +92,7 @@ export class GoldenLayoutAdapter extends React.PureComponent, *> { unbind() { this.unbindListeners.forEach(unbind => unbind()); + this.unbindListeners = []; } rebuildLayout() { @@ -104,13 +106,13 @@ export class GoldenLayoutAdapter extends React.PureComponent, *> { if (onLayoutChange != null && this.gl.isInitialised) { onLayoutChange(this.gl.toConfig(), this.props.activeLayoutName); // Only when the maximized item changed, adjust css classes to not show hidden gl items. - if (this.maximizedItem !== !this.gl._maximisedItem) { + if (this.maximizedItem !== this.gl._maximisedItem) { // Gl needs a forced update when returning from maximized viewing // mode to render stacked components correctly. const needsUpdatedSize = this.gl._maximisedItem === null && this.maximizedItem != null; this.maximizedItem = this.gl._maximisedItem; - const allGlHeaderElemets = document.getElementsByClassName("lm_item"); - for (const element of allGlHeaderElemets) { + const allGlHeaderElements = document.getElementsByClassName("lm_item"); + for (const element of allGlHeaderElements) { if (this.maximizedItem) { // Show only the maximized item and do not hide the gl root component. if ( @@ -132,6 +134,26 @@ export class GoldenLayoutAdapter extends React.PureComponent, *> { } } + attachMaximizeListener() { + const toggleMaximize = () => { + // Only maximize the element the mouse is over + const hoveredComponents = this.gl.root.getItemsByFilter( + item => item.isComponent && item.element[0].matches(":hover"), + ); + if (hoveredComponents.length > 0) { + const hoveredItem = hoveredComponents[0]; + // Maximize the container of the item not only the item itself, otherwise the header is not visible + hoveredItem.parent.toggleMaximise(); + } + }; + + const keyboardNoLoop = new InputKeyboardNoLoop( + { ".": toggleMaximize }, + { supportInputElements: false }, + ); + return () => keyboardNoLoop.destroy(); + } + setupLayout() { const activeLayout = getLayoutConfig(this.props.layoutKey, this.props.activeLayoutName); const gl = new GoldenLayout(activeLayout, `#${this.props.id}`); @@ -153,10 +175,16 @@ export class GoldenLayoutAdapter extends React.PureComponent, *> { }, true, ); + const unbindMaximizeListener = this.attachMaximizeListener(); gl.on("stateChanged", () => this.onStateChange()); - this.unbindListeners = [unbindResetListener, unbindChangedScaleListener, unbindResizeListener]; + this.unbindListeners = [ + unbindResetListener, + unbindChangedScaleListener, + unbindResizeListener, + unbindMaximizeListener, + ]; updateSize(); // The timeout is necessary since react cannot deal with react.render calls (which goldenlayout executes) diff --git a/frontend/javascripts/oxalis/view/right-menu/abstract_tree_tab_view.js b/frontend/javascripts/oxalis/view/right-menu/abstract_tree_tab_view.js index 17bb514863f..777bddcbd0b 100644 --- a/frontend/javascripts/oxalis/view/right-menu/abstract_tree_tab_view.js +++ b/frontend/javascripts/oxalis/view/right-menu/abstract_tree_tab_view.js @@ -30,6 +30,7 @@ type State = { class AbstractTreeView extends Component { canvas: ?HTMLCanvasElement; + nodeList: Array = []; state = { visible: false, }; @@ -47,7 +48,7 @@ class AbstractTreeView extends Component { window.removeEventListener("resize", this.drawTree, false); } - nodeList: Array = []; + // eslint-disable-next-line react/sort-comp drawTree = _.throttle(() => { if (!this.props.skeletonTracing || !this.state.visible) { return; diff --git a/frontend/javascripts/oxalis/view/right-menu/advanced_search_popover.js b/frontend/javascripts/oxalis/view/right-menu/advanced_search_popover.js new file mode 100644 index 00000000000..f3a16fbfa18 --- /dev/null +++ b/frontend/javascripts/oxalis/view/right-menu/advanced_search_popover.js @@ -0,0 +1,166 @@ +// @flow +import { Icon, Input, Tooltip, Popover } from "antd"; +import * as React from "react"; +import memoizeOne from "memoize-one"; + +import ButtonComponent from "oxalis/view/components/button_component"; +import Shortcut from "libs/shortcut_component"; +import DomVisibilityObserver from "oxalis/view/components/dom_visibility_observer"; +import { mod } from "libs/utils"; + +const InputGroup = Input.Group; + +type Props = { + data: Array, + searchKey: $Keys, + onSelect: S => void, + children: React.Node, + provideShortcut?: boolean, +}; + +type State = { + isVisible: boolean, + searchQuery: string, + currentPosition: ?number, +}; + +export default class AdvancedSearchPopover extends React.PureComponent, State> { + state = { + isVisible: false, + searchQuery: "", + currentPosition: null, + }; + + getAvailableOptions = memoizeOne( + (data: Array, searchQuery: string, searchKey: $Keys): Array => + searchQuery !== "" + ? data.filter( + datum => datum[searchKey].toLowerCase().indexOf(searchQuery.toLowerCase()) > -1, + ) + : [], + ); + + selectNextOptionWithOffset = (offset: number) => { + const { data, searchKey } = this.props; + const { searchQuery } = this.state; + let { currentPosition } = this.state; + + const availableOptions = this.getAvailableOptions(data, searchQuery, searchKey); + const numberOfAvailableOptions = availableOptions.length; + if (numberOfAvailableOptions === 0) { + return; + } + if (currentPosition == null) { + // If there was no previous currentPosition for the current search query, + // set currentPosition to an initial value. + currentPosition = offset >= 0 ? -1 : numberOfAvailableOptions; + } + // It can happen that currentPosition > availableOptions.length if trees are deleted. + // In that case taking the min ensures that the last available option is treated as + // selected and then the offset is added. + currentPosition = Math.min(currentPosition, numberOfAvailableOptions - 1); + currentPosition = mod(currentPosition + offset, numberOfAvailableOptions); + this.setState({ currentPosition }); + this.props.onSelect(availableOptions[currentPosition]); + }; + + selectNextOption = () => { + this.selectNextOptionWithOffset(1); + }; + + selectPreviousOption = () => { + this.selectNextOptionWithOffset(-1); + }; + + openSearchPopover = () => { + this.setState({ isVisible: true }); + }; + + closeSearchPopover = () => { + this.setState({ isVisible: false }); + }; + + render() { + const { data, searchKey, provideShortcut, children } = this.props; + const { searchQuery, isVisible } = this.state; + let { currentPosition } = this.state; + const availableOptions = this.getAvailableOptions(data, searchQuery, searchKey); + const numberOfAvailableOptions = availableOptions.length; + // Ensure that currentPosition to not higher than numberOfAvailableOptions. + currentPosition = + currentPosition == null ? -1 : Math.min(currentPosition, numberOfAvailableOptions - 1); + const hasNoResults = numberOfAvailableOptions === 0; + const hasMultipleResults = numberOfAvailableOptions > 1; + const additionalInputStyle = hasNoResults && searchQuery !== "" ? { color: "red" } : {}; + return ( + + {provideShortcut ? ( + + {isVisibleInDom => + isVisibleInDom && ( + + ) + } + + ) : null} + + newVisibility ? this.openSearchPopover() : this.closeSearchPopover() + } + content={ + // Only render search components when the popover is visible + // This ensures that the component is completely re-mounted when + // the popover is opened. Thus unnecessary computations are avoided. + isVisible && ( + + + + + this.setState({ searchQuery: evt.target.value, currentPosition: null }) + } + addonAfter={`${currentPosition + 1}/${numberOfAvailableOptions}`} + autoFocus + /> + + + + + + + + + + + + + ) + } + > + {children} + + + ); + } +} diff --git a/frontend/javascripts/oxalis/view/right-menu/comment_tab/comment_tab_view.js b/frontend/javascripts/oxalis/view/right-menu/comment_tab/comment_tab_view.js index 779f935ae3b..ffb5b0d7ceb 100644 --- a/frontend/javascripts/oxalis/view/right-menu/comment_tab/comment_tab_view.js +++ b/frontend/javascripts/oxalis/view/right-menu/comment_tab/comment_tab_view.js @@ -94,6 +94,14 @@ type CommentTabState = { class CommentTabView extends React.PureComponent { listRef: ?List; + storePropertyUnsubscribers: Array<() => void> = []; + keyboard = new InputKeyboard( + { + n: () => this.nextComment(), + p: () => this.previousComment(), + }, + { delay: Store.getState().userConfiguration.keyboardDelay }, + ); state = { isSortedAscending: true, @@ -151,16 +159,6 @@ class CommentTabView extends React.PureComponent void> = []; - - keyboard = new InputKeyboard( - { - n: () => this.nextComment(), - p: () => this.previousComment(), - }, - { delay: Store.getState().userConfiguration.keyboardDelay }, - ); - nextComment = (forward: boolean = true) => { getActiveNode(this.props.skeletonTracing).map(activeNode => { const { isSortedAscending, sortBy } = this.state; diff --git a/frontend/javascripts/oxalis/view/right-menu/dataset_info_tab_view.js b/frontend/javascripts/oxalis/view/right-menu/dataset_info_tab_view.js index 8548bb51357..751b2c24025 100644 --- a/frontend/javascripts/oxalis/view/right-menu/dataset_info_tab_view.js +++ b/frontend/javascripts/oxalis/view/right-menu/dataset_info_tab_view.js @@ -23,7 +23,7 @@ import { import ButtonComponent from "oxalis/view/components/button_component"; import EditableTextLabel from "oxalis/view/components/editable_text_label"; import Model from "oxalis/model"; -import Store, { type Flycam, type OxalisState, type Task, type Tracing } from "oxalis/store"; +import Store, { type OxalisState, type Task, type Tracing } from "oxalis/store"; type OwnProps = {| portalKey: string, @@ -31,7 +31,6 @@ type OwnProps = {| type StateProps = {| tracing: Tracing, dataset: APIDataset, - flycam: Flycam, task: ?Task, activeUser: ?APIUser, |}; @@ -344,7 +343,6 @@ class DatasetInfoTabView extends React.PureComponent { const mapStateToProps = (state: OxalisState): StateProps => ({ tracing: state.tracing, dataset: state.dataset, - flycam: state.flycam, task: state.task, activeUser: state.activeUser, }); diff --git a/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js b/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js index 98986758826..bac5dd44874 100644 --- a/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js +++ b/frontend/javascripts/oxalis/view/right-menu/mapping_info_view.js @@ -44,6 +44,7 @@ type StateProps = {| setAvailableMappingsForLayer: (string, Array) => void, activeViewport: OrthoView, activeCellId: number, + isMergerModeEnabled: boolean, |}; type Props = {| ...OwnProps, ...StateProps |}; @@ -80,6 +81,8 @@ const convertCellIdToCSS = (id, customColors) => const hasSegmentation = () => Model.getSegmentationLayer() != null; class MappingInfoView extends React.Component { + isMounted: boolean = false; + state = { shouldMappingBeEnabled: false, isRefreshingMappingList: false, @@ -106,8 +109,7 @@ class MappingInfoView extends React.Component { cube.off("volumeLabeled", this._forceUpdate); } - isMounted: boolean = false; - + // eslint-disable-next-line react/sort-comp _forceUpdate = _.throttle(() => { if (!this.isMounted) { return; @@ -252,44 +254,47 @@ class MappingInfoView extends React.Component { return (
{this.renderIdTable()} - -
-
- -
- - {/* + {/* Only display the mapping selection when merger mode is not active + to avoid conflicts in the logic of the UI. */ + !this.props.isMergerModeEnabled ? ( +
+
+ +
+ + {/* Show mapping-select even when the mapping is disabled but the UI was used before (i.e., mappingName != null) */} - {this.state.shouldMappingBeEnabled || this.props.mappingName != null ? ( - - ) : null} -
+ {this.state.shouldMappingBeEnabled || this.props.mappingName != null ? ( + + ) : null} +
+ ) : null}
); } @@ -319,6 +324,7 @@ function mapStateToProps(state: OxalisState) { activeCellId: getVolumeTracing(state.tracing) .map(tracing => tracing.activeCellId) .getOrElse(0), + isMergerModeEnabled: state.temporaryConfiguration.isMergerModeEnabled, }; } diff --git a/frontend/javascripts/oxalis/view/right-menu/search_popover.js b/frontend/javascripts/oxalis/view/right-menu/search_popover.js index 42b229f6ade..8b283707752 100644 --- a/frontend/javascripts/oxalis/view/right-menu/search_popover.js +++ b/frontend/javascripts/oxalis/view/right-menu/search_popover.js @@ -13,7 +13,7 @@ type Props = { searchKey: $Keys, idKey: $Keys, onSelect: number => void, - children: *, + children: React.Node, provideShortcut?: boolean, }; diff --git a/frontend/javascripts/oxalis/view/right-menu/trees_tab_view.js b/frontend/javascripts/oxalis/view/right-menu/trees_tab_view.js index 49266c4dab5..698112a7f97 100644 --- a/frontend/javascripts/oxalis/view/right-menu/trees_tab_view.js +++ b/frontend/javascripts/oxalis/view/right-menu/trees_tab_view.js @@ -8,7 +8,7 @@ import { connect } from "react-redux"; import { saveAs } from "file-saver"; import * as React from "react"; import _ from "lodash"; - +import memoizeOne from "memoize-one"; import { binaryConfirm } from "libs/async_confirm"; import { createGroupToTreesMap, @@ -33,6 +33,7 @@ import { setActiveTreeAction, deselectActiveTreeAction, deselectActiveGroupAction, + setActiveGroupAction, setTreeGroupAction, setTreeGroupsAction, addTreesAndGroupsAction, @@ -44,6 +45,8 @@ import Store, { type OxalisState, type SkeletonTracing, type Tracing, + type Tree, + type TreeMap, type TreeGroup, type UserConfiguration, } from "oxalis/store"; @@ -54,11 +57,17 @@ import api from "oxalis/api/internal_api"; import messages from "messages"; import DeleteGroupModalView from "./delete_group_modal_view"; -import SearchPopover from "./search_popover"; +import AdvancedSearchPopover from "./advanced_search_popover"; const ButtonGroup = Button.Group; const InputGroup = Input.Group; +type TreeOrTreeGroup = { + name: string, + id: number, + type: string, +}; + type OwnProps = {| portalKey: string, |}; @@ -79,6 +88,7 @@ type StateProps = {| userConfiguration: UserConfiguration, onSetActiveTree: number => void, onDeselectActiveTree: () => void, + onSetActiveGroup: number => void, onDeselectActiveGroup: () => void, showDropzoneModal: () => void, |}; @@ -145,6 +155,38 @@ class TreesTabView extends React.PureComponent { groupToDelete: null, }; + getTreeAndTreeGroupList = memoizeOne( + (trees: TreeMap, treeGroups: Array, sortBy: string): Array => { + const groupToTreesMap = createGroupToTreesMap(trees); + const rootGroup = { name: "Root", groupId: MISSING_GROUP_ID, children: treeGroups }; + + const makeTree = tree => ({ name: tree.name, type: "TREE", id: tree.treeId }); + const makeGroup = group => ({ name: group.name, type: "GROUP", id: group.groupId }); + + function* mapGroupsAndTreesSorted( + _groups: Array, + _groupToTreesMap: { [number]: Array }, + _sortBy: string, + ): Generator { + for (const group of _groups) { + yield makeGroup(group); + if (group.children) { + // Groups are always sorted by name and appear before the trees + const sortedGroups = _.orderBy(group.children, ["name"], ["asc"]); + yield* mapGroupsAndTreesSorted(sortedGroups, _groupToTreesMap, sortBy); + } + if (_groupToTreesMap[group.groupId] != null) { + // Trees are sorted by the sortBy property + const sortedTrees = _.orderBy(_groupToTreesMap[group.groupId], [_sortBy], ["asc"]); + yield* sortedTrees.map(makeTree); + } + } + } + + return Array.from(mapGroupsAndTreesSorted([rootGroup], groupToTreesMap, sortBy)); + }, + ); + handleChangeTreeName = (evt: SyntheticInputEvent<>) => { if (!this.props.skeletonTracing) { return; @@ -213,9 +255,9 @@ class TreesTabView extends React.PureComponent { this.props.onDeleteMultipleTrees(selectedTrees); this.setState({ selectedTrees: [] }); }; - this.showModalConfimWarning( + this.showModalConfirmWarning( "Delete all selected trees?", - messages["tracing.delete_mulitple_trees"]({ + messages["tracing.delete_multiple_trees"]({ countOfTrees: numbOfSelectedTrees, }), deleteAllSelectedTrees, @@ -270,7 +312,7 @@ class TreesTabView extends React.PureComponent { saveAs(blob, getNmlName(state)); }; - showModalConfimWarning(title: string, content: string, onConfirm: () => void) { + showModalConfirmWarning(title: string, content: string, onConfirm: () => void) { Modal.confirm({ title, content, @@ -337,28 +379,22 @@ class TreesTabView extends React.PureComponent { } }; - getAllSubtreeIdsOfGroup = (groupId: number): Array => { - if (!this.props.skeletonTracing) { - return []; - } - const { trees } = this.props.skeletonTracing; - const groupToTreesMap = createGroupToTreesMap(trees); - let subtreeIdsOfGroup = []; - if (groupToTreesMap[groupId]) { - subtreeIdsOfGroup = groupToTreesMap[groupId].map(node => node.treeId); - } - return subtreeIdsOfGroup; - }; - deselectAllTrees = () => { this.setState({ selectedTrees: [] }); }; - getTreesComponents() { + handleSearchSelect = (selectedElement: TreeOrTreeGroup) => { + if (selectedElement.type === "TREE") { + this.props.onSetActiveTree(selectedElement.id); + } else { + this.props.onSetActiveGroup(selectedElement.id); + } + }; + + getTreesComponents(sortBy: string) { if (!this.props.skeletonTracing) { return null; } - const orderAttribute = this.props.userConfiguration.sortTreesByName ? "name" : "timestamp"; return ( { treeGroups={this.props.skeletonTracing.treeGroups} activeTreeId={this.props.skeletonTracing.activeTreeId} activeGroupId={this.props.skeletonTracing.activeGroupId} - sortBy={orderAttribute} + sortBy={sortBy} selectedTrees={this.state.selectedTrees} onSelectTree={this.onSelectTree} deselectAllTrees={this.deselectAllTrees} @@ -453,6 +489,8 @@ class TreesTabView extends React.PureComponent { const activeGroupName = getActiveGroup(skeletonTracing) .map(activeGroup => activeGroup.name) .getOrElse(""); + const { trees, treeGroups } = skeletonTracing; + const orderAttribute = this.props.userConfiguration.sortTreesByName ? "name" : "timestamp"; // Avoid that the title switches to the other title during the fadeout of the Modal let title = ""; @@ -476,12 +514,10 @@ class TreesTabView extends React.PureComponent { - @@ -489,7 +525,7 @@ class TreesTabView extends React.PureComponent { - + Create @@ -532,7 +568,7 @@ class TreesTabView extends React.PureComponent {
    {this.getSelectedTreesAlert()}
    - {this.getTreesComponents()} + {this.getTreesComponents(orderAttribute)}
{groupToDelete !== null ? ( ) => ({ onDeselectActiveTree() { dispatch(deselectActiveTreeAction()); }, + onSetActiveGroup(groupId) { + dispatch(setActiveGroupAction(groupId)); + }, onDeselectActiveGroup() { dispatch(deselectActiveGroupAction()); }, diff --git a/frontend/javascripts/oxalis/view/settings/merger_mode_modal_view.js b/frontend/javascripts/oxalis/view/settings/merger_mode_modal_view.js new file mode 100644 index 00000000000..abd7da9f5bb --- /dev/null +++ b/frontend/javascripts/oxalis/view/settings/merger_mode_modal_view.js @@ -0,0 +1,61 @@ +// @flow +import * as React from "react"; +import { Modal, Button, Spin, Tooltip } from "antd"; + +type Props = { + isCloseable: boolean, + onClose: () => void, +}; + +export default function MergerModeModalView({ isCloseable, onClose }: Props) { + const closeButton = ( + + ); + return ( + + {!isCloseable ? ( + + {closeButton} + + ) : ( + closeButton + )} +
+ } + > + You just enabled the merger mode. This mode allows to merge segmentation cells by creating + trees and nodes. Each tree maps the marked segments (the ones where nodes were created in) to + one new segment. Create separate trees for different segements. +
+
+ Additionally available keyboard shortcuts: + + + + + + + + + + + +
8 + Replace the color of the current active tree and its mapped segments with a new one +
9Enable / disable displaying the segmentation.
+ {!isCloseable ? ( +
+ +
+ ) : null} + + ); +} diff --git a/frontend/javascripts/oxalis/view/settings/setting_input_views.js b/frontend/javascripts/oxalis/view/settings/setting_input_views.js index ebe9563b750..e2fa15e4084 100644 --- a/frontend/javascripts/oxalis/view/settings/setting_input_views.js +++ b/frontend/javascripts/oxalis/view/settings/setting_input_views.js @@ -140,18 +140,35 @@ type SwitchSettingProps = { onChange: (value: boolean) => void, value: boolean, label: string | React.Node, + disabled: boolean, + tooltipText: ?string, }; export class SwitchSetting extends React.PureComponent { + static defaultProps = { + disabled: false, + tooltipText: null, + }; + render() { - const { label, onChange, value } = this.props; + const { label, onChange, value, disabled, tooltipText } = this.props; return ( - + + {/* This div is necessary for the tooltip to be displayed */} +
+ +
+
); diff --git a/frontend/javascripts/oxalis/view/settings/user_settings_view.js b/frontend/javascripts/oxalis/view/settings/user_settings_view.js index 117e6461a3f..aea38b04fa9 100644 --- a/frontend/javascripts/oxalis/view/settings/user_settings_view.js +++ b/frontend/javascripts/oxalis/view/settings/user_settings_view.js @@ -1,5 +1,4 @@ /** - * tracing_settings_view.js * @flow */ @@ -18,6 +17,7 @@ import { LogSliderSetting, } from "oxalis/view/settings/setting_input_views"; import type { UserConfiguration, OxalisState, Tracing } from "oxalis/store"; +import type { APIDataset } from "admin/api_flow_types"; import { enforceSkeletonTracing, getActiveNode, @@ -30,6 +30,7 @@ import { setActiveNodeAction, setActiveTreeAction, setNodeRadiusAction, + setMergerModeEnabledAction, } from "oxalis/model/actions/skeletontracing_actions"; import { setUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; import { setZoomStepAction } from "oxalis/model/actions/flycam_actions"; @@ -42,9 +43,11 @@ import Constants, { type Vector6, } from "oxalis/constants"; import * as Utils from "libs/utils"; +import { enableMergerMode, disableMergerMode } from "oxalis/merger_mode"; import { userSettings } from "libs/user_settings.schema"; +import MergerModeModalView from "./merger_mode_modal_view"; -const Panel = Collapse.Panel; +const { Panel } = Collapse; type UserSettingsViewProps = { userConfiguration: UserConfiguration, @@ -58,12 +61,24 @@ type UserSettingsViewProps = { onChangeBoundingBox: (value: ?Vector6) => void, onChangeRadius: (value: number) => void, onChangeZoomStep: (value: number) => void, + onChangeEnableMergerMode: (active: boolean) => void, + isMergerModeEnabled: boolean, viewMode: ViewMode, controlMode: ControlMode, + dataset: APIDataset, +}; + +type State = { + isMergerModeModalVisible: boolean, + isMergerModeModalClosable: boolean, }; -class UserSettingsView extends PureComponent { +class UserSettingsView extends PureComponent { onChangeUser: { [$Keys]: Function }; + state = { + isMergerModeModalVisible: false, + isMergerModeModalClosable: false, + }; componentWillMount() { // cache onChange handler @@ -72,6 +87,26 @@ class UserSettingsView extends PureComponent { ); } + handleMergerModeChange = async (active: boolean) => { + this.props.onChangeEnableMergerMode(active); + if (active) { + this.setState({ + isMergerModeModalVisible: true, + isMergerModeModalClosable: false, + }); + await enableMergerMode(); + // The modal is only closeable after the merger mode is fully enabled + // and finished preprocessing + this.setState({ isMergerModeModalClosable: true }); + } else { + this.setState({ + isMergerModeModalVisible: false, + isMergerModeModalClosable: false, + }); + disableMergerMode(); + } + }; + getViewportOptions = () => { switch (this.props.viewMode) { case Constants.MODE_PLANE_TRACING: @@ -204,6 +239,10 @@ class UserSettingsView extends PureComponent { const activeNodeRadius = getActiveNode(skeletonTracing) .map(activeNode => activeNode.radius) .getOrElse(0); + const isMergerModeSupported = + (this.props.dataset.dataSource.dataLayers || []).find( + layer => layer.category === "segmentation" && layer.elementClass === "uint32", + ) != null; panels.push( { value={this.props.userConfiguration.highlightCommentedNodes} onChange={this.onChangeUser.highlightCommentedNodes} /> + { + this.handleMergerModeChange(value); + }} + disabled={!isMergerModeSupported} + tooltipText={ + !isMergerModeSupported + ? "The merger mode is only available for datasets with uint32 segmentations." + : null + } + /> , ); } @@ -282,6 +334,7 @@ class UserSettingsView extends PureComponent { }; render() { + const { isMergerModeModalVisible, isMergerModeModalClosable } = this.state; const moveValueSetting = Constants.MODES_ARBITRARY.includes(this.props.viewMode) ? ( { ); return ( - - - - {moveValueSetting} - - - {this.getViewportOptions()} - {this.getSkeletonOrVolumeOptions()} - - - + + + + {moveValueSetting} + + + {this.getViewportOptions()} + {this.getSkeletonOrVolumeOptions()} + + + + + + {isMergerModeModalVisible ? ( + this.setState({ isMergerModeModalVisible: false })} /> - - + ) : null} + ); } } @@ -348,6 +409,8 @@ const mapStateToProps = (state: OxalisState) => ({ maxZoomStep: getMaxZoomValue(state), viewMode: state.temporaryConfiguration.viewMode, controlMode: state.temporaryConfiguration.controlMode, + isMergerModeEnabled: state.temporaryConfiguration.isMergerModeEnabled, + dataset: state.dataset, }); const mapDispatchToProps = (dispatch: Dispatch<*>) => ({ @@ -372,6 +435,9 @@ const mapDispatchToProps = (dispatch: Dispatch<*>) => ({ onChangeRadius(radius: number) { dispatch(setNodeRadiusAction(radius)); }, + onChangeEnableMergerMode(active: boolean) { + dispatch(setMergerModeEnabledAction(active)); + }, }); export default connect( diff --git a/frontend/javascripts/oxalis/view/version_list.js b/frontend/javascripts/oxalis/view/version_list.js index 2ba69c97215..b2e7203cbb6 100644 --- a/frontend/javascripts/oxalis/view/version_list.js +++ b/frontend/javascripts/oxalis/view/version_list.js @@ -114,6 +114,7 @@ class VersionList extends React.Component { handlePreviewVersion = (version: number) => previewVersion({ [this.props.tracingType]: version }); + // eslint-disable-next-line react/sort-comp getGroupedAndChunkedVersions = _.memoize( (versions: Array): GroupedAndChunkedVersions => { // This function first groups the versions by day, where the key is the output of the moment calendar function. diff --git a/frontend/javascripts/oxalis/workers/async_bucket_picker.worker.js b/frontend/javascripts/oxalis/workers/async_bucket_picker.worker.js new file mode 100644 index 00000000000..c68ec093071 --- /dev/null +++ b/frontend/javascripts/oxalis/workers/async_bucket_picker.worker.js @@ -0,0 +1,92 @@ +// @flow +import PriorityQueue from "js-priority-queue"; + +import type { Area } from "oxalis/model/accessors/flycam_accessor"; +import type { LoadingStrategy } from "oxalis/store"; +import { M4x4 } from "libs/mjs"; +import constants, { + type OrthoViewMap, + type Vector3, + type Vector4, + type ViewMode, +} from "oxalis/constants"; +import determineBucketsForFlight from "oxalis/model/bucket_data_handling/bucket_picker_strategies/flight_bucket_picker"; +import determineBucketsForOblique from "oxalis/model/bucket_data_handling/bucket_picker_strategies/oblique_bucket_picker"; +import determineBucketsForOrthogonal from "oxalis/model/bucket_data_handling/bucket_picker_strategies/orthogonal_bucket_picker"; + +import { expose, pretendPromise } from "./comlink_wrapper"; + +const comparator = (b, a) => b.priority - a.priority; + +function dequeueToArrayBuffer( + bucketQueue: PriorityQueue<{ bucketAddress: Vector4, priority: number }>, +): ArrayBuffer { + const itemCount = bucketQueue.length; + const intsPerItem = 5; // [x, y, z, zoomStep, priority] + const bytesPerInt = 4; // Since we use uint32 + const buffer = new ArrayBuffer(itemCount * intsPerItem * bytesPerInt); + const bucketsWithPriorities = new Uint32Array(buffer); + let currentElementIndex = 0; + + while (bucketQueue.length > 0) { + const { bucketAddress, priority } = bucketQueue.dequeue(); + + const currentBufferIndex = currentElementIndex * intsPerItem; + bucketsWithPriorities[currentBufferIndex] = bucketAddress[0]; + bucketsWithPriorities[currentBufferIndex + 1] = bucketAddress[1]; + bucketsWithPriorities[currentBufferIndex + 2] = bucketAddress[2]; + bucketsWithPriorities[currentBufferIndex + 3] = bucketAddress[3]; + bucketsWithPriorities[currentBufferIndex + 4] = priority; + + currentElementIndex++; + } + return buffer; +} + +function pick( + viewMode: ViewMode, + resolutions: Array, + position: Vector3, + sphericalCapRadius: number, + matrix: M4x4, + logZoomStep: number, + loadingStrategy: LoadingStrategy, + anchorPoint: Vector4, + areas: OrthoViewMap, + subBucketLocality: Vector3, +): Promise { + const bucketQueue = new PriorityQueue({ + // small priorities take precedence + comparator, + }); + const enqueueFunction = (bucketAddress, priority) => { + bucketQueue.queue({ bucketAddress, priority }); + }; + + if (viewMode === constants.MODE_ARBITRARY_PLANE) { + determineBucketsForOblique(resolutions, position, enqueueFunction, matrix, logZoomStep); + } else if (viewMode === constants.MODE_ARBITRARY) { + determineBucketsForFlight( + resolutions, + position, + sphericalCapRadius, + enqueueFunction, + matrix, + logZoomStep, + ); + } else { + determineBucketsForOrthogonal( + resolutions, + enqueueFunction, + loadingStrategy, + logZoomStep, + anchorPoint, + areas, + subBucketLocality, + ); + } + + return pretendPromise(dequeueToArrayBuffer(bucketQueue)); +} + +export default expose(pick); diff --git a/frontend/javascripts/oxalis/workers/comlink_wrapper.js b/frontend/javascripts/oxalis/workers/comlink_wrapper.js index 4777111a3a7..c967f5c7b5a 100644 --- a/frontend/javascripts/oxalis/workers/comlink_wrapper.js +++ b/frontend/javascripts/oxalis/workers/comlink_wrapper.js @@ -44,11 +44,6 @@ export function createWorker(WorkerClass: UseCreateWorkerToUseMe): T { } return proxy( - // When importing a worker module, flow doesn't know that a special Worker class - // is imported. Instead, flow thinks that the declared function is - // directly imported. We exploit this by simply typing this createWorker function as an identity function - // (T => T). That way, we gain proper flow typing for functions executed in web workers. However, - // we need to suppress the following flow error for that to work. // $FlowIgnore new WorkerClass(), ); @@ -62,3 +57,14 @@ export function expose(fn: T): UseCreateWorkerToUseMe { // $FlowIgnore return fn; } + +export function pretendPromise(t: T): Promise { + // The top level function within a webworker doesn't necessarily + // need to return a promise. However, when called from the main thread + // we will always get a promise. Since, flow isn't able to express this + // for variadic function types, we have to cheat with the return type on + // the call side. For this scenario, this function can be used (see + // async_bucket_picker.worker.js as an example). + // $FlowIgnore + return t; +} diff --git a/frontend/javascripts/test/libs/latest_task_executor.spec.js b/frontend/javascripts/test/libs/latest_task_executor.spec.js new file mode 100644 index 00000000000..5dc90de8c5d --- /dev/null +++ b/frontend/javascripts/test/libs/latest_task_executor.spec.js @@ -0,0 +1,81 @@ +// @flow +import Deferred from "libs/deferred"; +import test from "ava"; +import LatestTaskExecutor, { SKIPPED_TASK_REASON } from "libs/latest_task_executor"; + +test("LatestTaskExecutor: One task", async t => { + const executor = new LatestTaskExecutor(); + + const deferred1 = new Deferred(); + + const scheduledPromise = executor.schedule(() => deferred1.promise()); + + deferred1.resolve(true); + return scheduledPromise.then(result => { + t.true(result); + }); +}); + +test("LatestTaskExecutor: two successive tasks", async t => { + const executor = new LatestTaskExecutor(); + + const deferred1 = new Deferred(); + const deferred2 = new Deferred(); + + const scheduledPromise1 = executor.schedule(() => deferred1.promise()); + deferred1.resolve(1); + + const scheduledPromise2 = executor.schedule(() => deferred2.promise()); + deferred2.resolve(2); + + await scheduledPromise1.then(result => { + t.is(result, 1); + }); + await scheduledPromise2.then(result => { + t.is(result, 2); + }); +}); + +test("LatestTaskExecutor: two interleaving tasks", async t => { + const executor = new LatestTaskExecutor(); + + const deferred1 = new Deferred(); + const deferred2 = new Deferred(); + + const scheduledPromise1 = executor.schedule(() => deferred1.promise()); + const scheduledPromise2 = executor.schedule(() => deferred2.promise()); + + deferred1.resolve(1); + deferred2.resolve(2); + + await scheduledPromise1.then(result => { + t.is(result, 1); + }); + await scheduledPromise2.then(result => { + t.is(result, 2); + }); +}); + +test("LatestTaskExecutor: three interleaving tasks", async t => { + const executor = new LatestTaskExecutor(); + + const deferred1 = new Deferred(); + const deferred2 = new Deferred(); + const deferred3 = new Deferred(); + + const scheduledPromise1 = executor.schedule(() => deferred1.promise()); + const scheduledPromise2 = executor.schedule(() => deferred2.promise()); + const scheduledPromise3 = executor.schedule(() => deferred3.promise()); + + deferred1.resolve(1); + deferred2.resolve(2); + deferred3.resolve(3); + + await scheduledPromise1.then(result => { + t.is(result, 1); + }); + t.throwsAsync(scheduledPromise2, SKIPPED_TASK_REASON); + await scheduledPromise3.then(result => { + t.is(result, 3); + }); +}); diff --git a/frontend/javascripts/test/model/position_converter.spec.js b/frontend/javascripts/test/model/position_converter.spec.js new file mode 100644 index 00000000000..3d8fa7967f9 --- /dev/null +++ b/frontend/javascripts/test/model/position_converter.spec.js @@ -0,0 +1,39 @@ +// @flow +import test from "ava"; + +import { getBaseBucketsForFallbackBucket } from "oxalis/model/helpers/position_converter"; + +test("position_converter should calculate base buckets for a given fallback bucket (isotropic)", t => { + const bucketAddresses = getBaseBucketsForFallbackBucket([1, 2, 3, 1], 1, [ + [1, 1, 1], + [2, 2, 2], + [4, 4, 4], + [8, 8, 8], + ]); + + const expectedBucketAddresses = [ + [2, 4, 6, 0], + [2, 4, 7, 0], + [2, 5, 6, 0], + [2, 5, 7, 0], + [3, 4, 6, 0], + [3, 4, 7, 0], + [3, 5, 6, 0], + [3, 5, 7, 0], + ]; + + t.deepEqual(bucketAddresses, expectedBucketAddresses); +}); + +test("position_converter should calculate base buckets for a given fallback bucket (anisotropic)", t => { + const bucketAddresses = getBaseBucketsForFallbackBucket([1, 2, 3, 1], 1, [ + [1, 1, 1], + [2, 2, 1], + [4, 4, 4], + [8, 8, 8], + ]); + + const expectedBucketAddresses = [[2, 4, 3, 0], [2, 5, 3, 0], [3, 4, 3, 0], [3, 5, 3, 0]]; + + t.deepEqual(bucketAddresses, expectedBucketAddresses); +}); diff --git a/frontend/javascripts/test/model/texture_bucket_manager.spec.js b/frontend/javascripts/test/model/texture_bucket_manager.spec.js index 869b188add7..dd6fcb16a57 100644 --- a/frontend/javascripts/test/model/texture_bucket_manager.spec.js +++ b/frontend/javascripts/test/model/texture_bucket_manager.spec.js @@ -72,7 +72,7 @@ const setActiveBucketsAndWait = (tbm, activeBuckets, anchorPoint) => { }; const expectBucket = (t, tbm, bucket, expectedFirstByte) => { - const bucketIdx = tbm._getBucketIndex(bucket); + const bucketIdx = tbm._getBucketIndex(bucket.zoomedAddress); const bucketLocation = tbm.getPackedBucketSize() * tbm.lookUpBuffer[channelCountForLookupBuffer * bucketIdx]; t.is(tbm.dataTextures[0].texture[bucketLocation], expectedFirstByte); diff --git a/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js b/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js index ff483daa3e6..82f637113f0 100644 --- a/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js +++ b/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js @@ -53,6 +53,7 @@ test.beforeEach(async t => { "--disable-setuid-sandbox", "--disable-dev-shm-usage", ], + dumpio: true, }); global.Headers = Headers; global.fetch = fetch; @@ -78,6 +79,7 @@ const viewOverrides: { [key: string]: string } = { e2006_knossos: "4736,4992,2176,0,0.6", "2017-05-31_mSEM_scMS109_bk_100um_v01-aniso": "4608,4543,386,0,4.00", ROI2017_wkw_fallback: "535,536,600,0,1.18", + dsA_2: "1024,1024,64,0,0.424", }; const datasetConfigOverrides: { [key: string]: DatasetConfiguration } = { @@ -94,28 +96,54 @@ const datasetConfigOverrides: { [key: string]: DatasetConfiguration } = { }, }; +async function withRetry( + retryCount: number, + testFn: () => Promise, + resolveFn: boolean => void, +) { + for (let i = 0; i < retryCount; i++) { + // eslint-disable-next-line no-await-in-loop + const condition = await testFn(); + if (condition || i === retryCount - 1) { + // Either the test passed or we executed the last attempt + resolveFn(condition); + return; + } + } +} + datasetNames.map(async datasetName => { test.serial(`it should render dataset ${datasetName} correctly`, async t => { - const datasetId = { name: datasetName, owningOrganization: "Connectomics_Department" }; - const { screenshot, width, height } = await screenshotDataset( - await getNewPage(t.context.browser), - URL, - datasetId, - viewOverrides[datasetName], - datasetConfigOverrides[datasetName], - ); - const changedPixels = await compareScreenshot( - screenshot, - width, - height, - BASE_PATH, - datasetName, - ); + await withRetry( + 3, + async () => { + const datasetId = { name: datasetName, owningOrganization: "Connectomics_Department" }; + const { screenshot, width, height } = await screenshotDataset( + await getNewPage(t.context.browser), + URL, + datasetId, + viewOverrides[datasetName], + datasetConfigOverrides[datasetName], + ); + const changedPixels = await compareScreenshot( + screenshot, + width, + height, + BASE_PATH, + datasetName, + ); - t.is( - changedPixels, - 0, - `Dataset with name: "${datasetName}" does not look the same, see ${datasetName}.diff.png for the difference and ${datasetName}.new.png for the new screenshot.`, + // There may be a difference of 0.1 % + const allowedThreshold = 0.1 / 100; + const allowedChangedPixel = allowedThreshold * width * height; + return changedPixels < allowedChangedPixel; + }, + condition => { + t.true( + condition, + `Dataset with name: "${datasetName}" does not look the same, see ${datasetName}.diff.png for the difference and ${datasetName}.new.png for the new screenshot.`, + ); + }, ); }); }); diff --git a/frontend/javascripts/test/screenshots/2017-05-31_mSEM_aniso-test.png b/frontend/javascripts/test/screenshots/2017-05-31_mSEM_aniso-test.png index b01f549bc3f..ccebab32dd1 100644 Binary files a/frontend/javascripts/test/screenshots/2017-05-31_mSEM_aniso-test.png and b/frontend/javascripts/test/screenshots/2017-05-31_mSEM_aniso-test.png differ diff --git a/frontend/javascripts/test/screenshots/2017-05-31_mSEM_scMS109_bk_100um_v01-aniso.png b/frontend/javascripts/test/screenshots/2017-05-31_mSEM_scMS109_bk_100um_v01-aniso.png index bb1e4afc0a9..143f490651c 100644 Binary files a/frontend/javascripts/test/screenshots/2017-05-31_mSEM_scMS109_bk_100um_v01-aniso.png and b/frontend/javascripts/test/screenshots/2017-05-31_mSEM_scMS109_bk_100um_v01-aniso.png differ diff --git a/frontend/javascripts/test/screenshots/Cortex_knossos.png b/frontend/javascripts/test/screenshots/Cortex_knossos.png index f508aa568b8..682972f0ba7 100644 Binary files a/frontend/javascripts/test/screenshots/Cortex_knossos.png and b/frontend/javascripts/test/screenshots/Cortex_knossos.png differ diff --git a/frontend/javascripts/test/screenshots/ROI2017_wkw.png b/frontend/javascripts/test/screenshots/ROI2017_wkw.png index cd725da359e..556bb250689 100644 Binary files a/frontend/javascripts/test/screenshots/ROI2017_wkw.png and b/frontend/javascripts/test/screenshots/ROI2017_wkw.png differ diff --git a/frontend/javascripts/test/screenshots/ROI2017_wkw_fallback.png b/frontend/javascripts/test/screenshots/ROI2017_wkw_fallback.png index 8ae894441ea..425d133fa2c 100644 Binary files a/frontend/javascripts/test/screenshots/ROI2017_wkw_fallback.png and b/frontend/javascripts/test/screenshots/ROI2017_wkw_fallback.png differ diff --git a/frontend/javascripts/test/screenshots/confocal-multi_knossos.png b/frontend/javascripts/test/screenshots/confocal-multi_knossos.png index baa495a289f..240d60e6bdb 100644 Binary files a/frontend/javascripts/test/screenshots/confocal-multi_knossos.png and b/frontend/javascripts/test/screenshots/confocal-multi_knossos.png differ diff --git a/frontend/javascripts/test/screenshots/dsA_2.png b/frontend/javascripts/test/screenshots/dsA_2.png index ffea9d1748d..444d582d71c 100644 Binary files a/frontend/javascripts/test/screenshots/dsA_2.png and b/frontend/javascripts/test/screenshots/dsA_2.png differ diff --git a/frontend/javascripts/test/screenshots/e2006_knossos.png b/frontend/javascripts/test/screenshots/e2006_knossos.png index 546690a129a..64b0f06d34e 100644 Binary files a/frontend/javascripts/test/screenshots/e2006_knossos.png and b/frontend/javascripts/test/screenshots/e2006_knossos.png differ diff --git a/frontend/javascripts/test/screenshots/fluro-rgb_knossos.png b/frontend/javascripts/test/screenshots/fluro-rgb_knossos.png index e108cd69eb7..ea9b8297c03 100644 Binary files a/frontend/javascripts/test/screenshots/fluro-rgb_knossos.png and b/frontend/javascripts/test/screenshots/fluro-rgb_knossos.png differ diff --git a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md index aba5e435c64..461c2f26104 100644 --- a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.md @@ -157,6 +157,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: false, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: 'confocal-multi_knossos', @@ -234,6 +235,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: false, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: 'e2006_knossos', @@ -277,6 +279,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: true, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: '2012-06-28_Cortex', @@ -315,6 +318,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: true, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: '2012-09-28_ex145_07x2', @@ -353,6 +357,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: true, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: 'Experiment_001', @@ -444,6 +449,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: false, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: 'confocal-multi_knossos', @@ -521,6 +527,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: false, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: 'e2006_knossos', @@ -559,6 +566,7 @@ Generated by [AVA](https://ava.li). isEditable: true, isForeign: false, isPublic: false, + isUnreported: true, lastUsedByUser: 0, logoUrl: '/images/mpi-logos.svg', name: 'rgb', diff --git a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap index d5911d06dc3..cefb6e4b6e7 100644 Binary files a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap and b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/datasets.e2e.js.snap differ diff --git a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md index 3ed875ad62b..1182edc01fb 100644 --- a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.md @@ -44,6 +44,13 @@ Generated by [AVA](https://ava.li). ## misc-datastores [ + { + isConnector: true, + isForeign: false, + isScratch: false, + name: 'connect', + url: 'http://localhost:8000', + }, { isConnector: false, isForeign: false, diff --git a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap index f166f529e96..12ad6b9d058 100644 Binary files a/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap and b/frontend/javascripts/test/snapshots/public/test-bundle/test/backend-snapshot-tests/misc.e2e.js.snap differ diff --git a/frontend/javascripts/test/snapshots/public/test-bundle/test/enzyme/snapshot.e2e.js.md b/frontend/javascripts/test/snapshots/public/test-bundle/test/enzyme/snapshot.e2e.js.md index 241a5af86e1..0055789ad49 100644 --- a/frontend/javascripts/test/snapshots/public/test-bundle/test/enzyme/snapshot.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public/test-bundle/test/enzyme/snapshot.e2e.js.md @@ -59,7 +59,7 @@ Generated by [AVA](https://ava.li).
␊ -
␊ +
␊ ␊ ␊ + ␊ + ␊ @@ -96,6 +98,32 @@ Generated by [AVA](https://ava.li). ␊ ␊ ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +

␊ Datasets␊ @@ -103,8 +131,8 @@ Generated by [AVA](https://ava.li).
␊ - ␊ - ␊ + ␊ +
␊ @@ -112,10 +140,10 @@ Generated by [AVA](https://ava.li).
␊ -
␊ +
␊ - ␊ - ␊ + ␊ +
␊ @@ -237,8 +265,8 @@ Generated by [AVA](https://ava.li). ␊ ␊
␊ - ␊ - ␊ + ␊ + ␊ @@ -403,8 +431,8 @@ Generated by [AVA](https://ava.li). ␊ - ␊ - ␊ + ␊ + ␊ @@ -580,515 +608,75 @@ Generated by [AVA](https://ava.li). ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ +
␊ - ␊ - ␊ - ␊ - ␊ -
␊ - rgb␊ -
␊ - ␊ - ␊ - ␊ -
␊ - localhost␊ +
␊ + ␊ + ␊
␊ - ␊ - ␊ - ␊ + ␊
␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - 2016-04-11 15:00␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ -
␊ - team_X1␊ +
␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +

␊ ␊ +
␊ ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ +
␊ -
␊ - ␊ - ␊ - ␊ - ␊ - ␊ - Import␊ - ␊ - ␊ -
␊ - No longer available on datastore.␊ + ␊
␊ + ␊ + ␊
␊ + ␊
␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ -
␊ - 2012-06-28_Cortex␊ -
␊ - ␊ - ␊ - ␊ -
␊ - localhost␊ -
␊ -
␊ -
␊ -
␊ + ␊
␊ - ␊ -
␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - 2016-04-11 14:57␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ -
␊ - team_X1␊ + ␊ + ␊ + ␊ - ␊ -
␊ -
␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ + ␊ + ␊ + ␊ + ␊ ␊ ␊ ␊ @@ -1150,7 +738,7 @@ Generated by [AVA](https://ava.li).
␊ -
␊ +
␊ ␊ + ␊ + ␊ @@ -1187,6 +777,32 @@ Generated by [AVA](https://ava.li). ␊ ␊ ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +

␊ Datasets␊ @@ -1194,8 +810,8 @@ Generated by [AVA](https://ava.li).
␊ - ␊ - ␊ + ␊ +
␊ @@ -1203,10 +819,10 @@ Generated by [AVA](https://ava.li).
␊ -
␊ +
␊ - ␊ - ␊ + ␊ +
␊ @@ -1328,8 +944,8 @@ Generated by [AVA](https://ava.li). ␊ ␊
␊ - ␊ - ␊ + ␊ + ␊ @@ -1494,8 +1110,8 @@ Generated by [AVA](https://ava.li). ␊ - ␊ - ␊ + ␊ + ␊ @@ -1671,615 +1287,322 @@ Generated by [AVA](https://ava.li). ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ - ␊ +
␊ - ␊ - ␊ - ␊ - ␊ -
␊ - rgb␊ -
␊ - ␊ - ␊ - ␊ -
␊ - localhost␊ +
␊ + ␊ + ␊
␊ - ␊ - ␊ - ␊ + ␊
␊ - ␊ - ␊ - ␊ - ␊ - ␊ +

␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +
␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ +