diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 49cc84039f1..6152f1dcc22 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -14,6 +14,10 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Added the total volume of a dataset to a tooltip in the dataset info tab. [#8229](https://github.com/scalableminds/webknossos/pull/8229) - Optimized performance of data loading with “fill value“ chunks. [#8271](https://github.com/scalableminds/webknossos/pull/8271) - Added the option to export proofreading as segmentation [#8286](https://github.com/scalableminds/webknossos/pull/8286) +- The fill tool can now be adapted so that it only acts within a specified bounding box. Use the new "Restrict Floodfill" mode for that in the toolbar. [#8267](https://github.com/scalableminds/webknossos/pull/8267) +- Added the option for "Selective Segment Visibility" for segmentation layers. Select this option in the left sidebar to only show segments that are currently active or hovered. [#8281](https://github.com/scalableminds/webknossos/pull/8281) +- A segment can be activated with doubleclick now. [#8281](https://github.com/scalableminds/webknossos/pull/8281) +- It is now possible to select the magnification of the layers on which an AI model will be trained. [#8266](https://github.com/scalableminds/webknossos/pull/8266) ### Changed - Renamed "resolution" to "magnification" in more places within the codebase, including local variables. [#8168](https://github.com/scalableminds/webknossos/pull/8168) @@ -22,6 +26,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Improved the default colors for skeleton trees. [#8228](https://github.com/scalableminds/webknossos/pull/8228) - Allowed to train an AI model using differently sized bounding boxes. We recommend all bounding boxes to have equal dimensions or to have dimensions which are multiples of the smallest bounding box. [#8222](https://github.com/scalableminds/webknossos/pull/8222) - Within the bounding box tool, the cursor updates immediately after pressing `ctrl`, indicating that a bounding box can be moved instead of resized. [#8253](https://github.com/scalableminds/webknossos/pull/8253) +- Improved the styling of active tools and modes in the toolbar. [#8295](https://github.com/scalableminds/webknossos/pull/8295) ### Fixed - Fixed that listing datasets with the `api/datasets` route without compression failed due to missing permissions regarding public datasets. [#8249](https://github.com/scalableminds/webknossos/pull/8249) @@ -29,15 +34,21 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Fixed a bug that uploading a zarr dataset with an already existing `datasource-properties.json` file failed. [#8268](https://github.com/scalableminds/webknossos/pull/8268) - Fixed the organization switching feature for datasets opened via old links. [#8257](https://github.com/scalableminds/webknossos/pull/8257) - Fixed that uploading an NML file without an organization id failed. Now the user's organization is used as fallback. [#8277](https://github.com/scalableminds/webknossos/pull/8277) -- Fixed that the frontend did not ensure a minium length for annotation layer names. Moreover, names starting with a `.` are also disallowed now. [#8244](https://github.com/scalableminds/webknossos/pull/8244) +- Fixed that the frontend did not ensure a minimum length for annotation layer names. Moreover, names starting with a `.` are also disallowed now. [#8244](https://github.com/scalableminds/webknossos/pull/8244) - Fixed a bug where in the add remote dataset view the dataset name setting was not in sync with the datasource setting of the advanced tab making the form not submittable. [#8245](https://github.com/scalableminds/webknossos/pull/8245) - Fix read and update dataset route for versions 8 and lower. [#8263](https://github.com/scalableminds/webknossos/pull/8263) - Fixed that task bounding boxes are again converted to user bounding boxes when uploading annotations via nmls. [#8280](https://github.com/scalableminds/webknossos/pull/8280) - Added missing legacy support for `isValidNewName` route. [#8252](https://github.com/scalableminds/webknossos/pull/8252) - Fixed some layout issues in the upload view. [#8231](https://github.com/scalableminds/webknossos/pull/8231) - Fixed `FATAL: role "postgres" does not exist` error message in Docker compose. [#8240](https://github.com/scalableminds/webknossos/pull/8240) +- Fixed the Zarr 3 implementation not accepting BytesCodec without "configuration" key. [#8282](https://github.com/scalableminds/webknossos/pull/8282) +- Fixed that reloading the data of a volume annotation layer did not work properly. [#8298](https://github.com/scalableminds/webknossos/pull/8298) +- Removed the magnification slider for the TIFF export within the download modal if only one magnification is available for the selected layer. [#8297](https://github.com/scalableminds/webknossos/pull/8297) +- Fixed regression in styling of segment and skeleton tree tab. [#8307](https://github.com/scalableminds/webknossos/pull/8307) +- Fixed the template for neuron inferral using a custom workflow. [#8312](https://github.com/scalableminds/webknossos/pull/8312) ### Removed - Removed support for HTTP API versions 3 and 4. [#8075](https://github.com/scalableminds/webknossos/pull/8075) +- Removed that a warning is shown when a dataset is served from a datastore that was marked with isScratch=true. [#8296](https://github.com/scalableminds/webknossos/pull/8296) ### Breaking Changes diff --git a/app/WebknossosModule.scala b/app/WebknossosModule.scala index 8832d9b5a95..69cd6d79b2d 100644 --- a/app/WebknossosModule.scala +++ b/app/WebknossosModule.scala @@ -1,6 +1,6 @@ import com.google.inject.AbstractModule import com.scalableminds.webknossos.datastore.storage.DataVaultService -import controllers.InitialDataService +import controllers.{Application, InitialDataService} import files.TempFileService import mail.MailchimpTicker import models.analytics.AnalyticsSessionService @@ -17,6 +17,7 @@ import utils.sql.SqlClient class WebknossosModule extends AbstractModule { override def configure(): Unit = { + bind(classOf[Application]).asEagerSingleton() bind(classOf[Startup]).asEagerSingleton() bind(classOf[SqlClient]).asEagerSingleton() bind(classOf[InitialDataService]).asEagerSingleton() diff --git a/app/controllers/Application.scala b/app/controllers/Application.scala index b781a488efc..a1249fac357 100755 --- a/app/controllers/Application.scala +++ b/app/controllers/Application.scala @@ -7,7 +7,7 @@ import models.organization.OrganizationDAO import models.user.UserService import org.apache.pekko.actor.ActorSystem import play.api.libs.json.Json -import play.api.mvc.{Action, AnyContent} +import play.api.mvc.{Action, AnyContent, Result} import play.silhouette.api.Silhouette import security.WkEnv import utils.sql.{SimpleSQLDAO, SqlClient} @@ -51,9 +51,12 @@ class Application @Inject()(actorSystem: ActorSystem, } } + // This only changes on server restart, so we can cache the full result. + private lazy val cachedFeaturesResult: Result = addNoCacheHeaderFallback( + Ok(conf.raw.underlying.getConfig("features").resolve.root.render(ConfigRenderOptions.concise())).as(jsonMimeType)) + def features: Action[AnyContent] = sil.UserAwareAction { - addNoCacheHeaderFallback( - Ok(conf.raw.underlying.getConfig("features").resolve.root.render(ConfigRenderOptions.concise())).as(jsonMimeType)) + cachedFeaturesResult } def health: Action[AnyContent] = Action { diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index c186e6203ab..4da99a69d5f 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -277,7 +277,8 @@ class AuthenticationController @Inject()( case None => Future.successful(NotFound(Messages("error.noUser"))) case Some(user) => for { - token <- bearerTokenAuthenticatorService.createAndInit(user.loginInfo, TokenType.ResetPassword) + token <- bearerTokenAuthenticatorService + .createAndInit(user.loginInfo, TokenType.ResetPassword, deleteOld = true) } yield { Mailer ! Send(defaultMails.resetPasswordMail(user.name, email.toLowerCase, token)) Ok diff --git a/app/controllers/LegacyApiController.scala b/app/controllers/LegacyApiController.scala index cb8d983285f..bc574778a20 100644 --- a/app/controllers/LegacyApiController.scala +++ b/app/controllers/LegacyApiController.scala @@ -127,20 +127,6 @@ class LegacyApiController @Inject()(annotationController: AnnotationController, } yield adaptedResult } - def updateTaskV8(taskId: String): Action[LegacyTaskParameters] = - sil.SecuredAction.async(validateJson[LegacyTaskParameters]) { implicit request => - val params = request.body - for { - dataset <- datasetDAO.findOneByIdOrNameAndOrganization(params.datasetId, - params.dataSet, - request.identity._organization) - paramsWithDatasetId = TaskParameters.fromLegacyTaskParameters(params, dataset._id) - requestWithUpdatedBody = request.withBody(paramsWithDatasetId) - result <- taskController.update(taskId)(requestWithUpdatedBody) - adaptedResult <- replaceInResult(addLegacyDataSetFieldToTask)(result) - } yield adaptedResult - } - def tasksForProjectV8(id: String, limit: Option[Int] = None, pageNumber: Option[Int] = None, @@ -153,23 +139,6 @@ class LegacyApiController @Inject()(annotationController: AnnotationController, } yield replacedResults } - def annotationInfoV8(id: String, timestamp: Long): Action[AnyContent] = sil.SecuredAction.async { implicit request => - for { - _ <- Fox.successful(logVersioned(request)) - result <- annotationController.infoWithoutType(id, timestamp)(request) - adaptedResult <- replaceInResult(addDataSetToTaskInAnnotation)(result) - } yield adaptedResult - } - - def annotationsForTaskV8(taskId: String): Action[AnyContent] = - sil.SecuredAction.async { implicit request => - for { - _ <- Fox.successful(logVersioned(request)) - result <- annotationController.annotationsForTask(taskId)(request) - adaptedResult <- replaceInResult(addDataSetToTaskInAnnotation)(result) - } yield adaptedResult - } - /* provide v7 */ def listDatasetsV7(isActive: Option[Boolean], @@ -256,17 +225,6 @@ class LegacyApiController @Inject()(annotationController: AnnotationController, } } - private def addDataSetToTaskInAnnotation(jsResult: JsObject): Fox[JsObject] = { - val taskObjectOpt = (jsResult \ "task").asOpt[JsObject] - taskObjectOpt - .map(task => - for { - adaptedTask <- addLegacyDataSetFieldToTask(task) - adaptedJsResult <- tryo(jsResult - "task" + ("task" -> adaptedTask)).toFox - } yield adaptedJsResult) - .getOrElse(Fox.successful(jsResult)) - } - private def addLegacyDataSetFieldToTaskCreationResult(jsResult: JsObject) = for { tasksResults <- tryo((jsResult \ "tasks").as[List[JsObject]]).toFox diff --git a/app/controllers/OrganizationController.scala b/app/controllers/OrganizationController.scala index f1bd61c9c4e..f7bf6170794 100755 --- a/app/controllers/OrganizationController.scala +++ b/app/controllers/OrganizationController.scala @@ -41,9 +41,9 @@ class OrganizationController @Inject()( def organizationsIsEmpty: Action[AnyContent] = Action.async { implicit request => for { - allOrgs <- organizationDAO.findAll(GlobalAccessContext) ?~> "organization.list.failed" + orgaTableIsEmpty <- organizationDAO.isEmpty ?~> "organization.list.failed" } yield { - Ok(Json.toJson(allOrgs.isEmpty)) + Ok(Json.toJson(orgaTableIsEmpty)) } } diff --git a/app/models/dataset/DataStore.scala b/app/models/dataset/DataStore.scala index e4450c3cf15..69ea6e0053a 100644 --- a/app/models/dataset/DataStore.scala +++ b/app/models/dataset/DataStore.scala @@ -71,7 +71,6 @@ class DataStoreService @Inject()(dataStoreDAO: DataStoreDAO, jobService: JobServ Json.obj( "name" -> dataStore.name, "url" -> dataStore.publicUrl, - "isScratch" -> dataStore.isScratch, "allowsUpload" -> dataStore.allowsUpload, "jobsSupportedByAvailableWorkers" -> Json.toJson(jobsSupportedByAvailableWorkers), "jobsEnabled" -> jobsEnabled diff --git a/app/models/organization/Organization.scala b/app/models/organization/Organization.scala index f7896604abf..cc4f701eae1 100644 --- a/app/models/organization/Organization.scala +++ b/app/models/organization/Organization.scala @@ -80,6 +80,12 @@ class OrganizationDAO @Inject()(sqlClient: SqlClient)(implicit ec: ExecutionCont parsed <- parseAll(r) } yield parsed + def isEmpty: Fox[Boolean] = + for { + rows <- run(q"SELECT COUNT(*) FROM $existingCollectionName".as[Int]) + value <- rows.headOption + } yield value == 0 + @deprecated("use findOne with string type instead", since = "") override def findOne(id: ObjectId)(implicit ctx: DBAccessContext): Fox[Organization] = Fox.failure("Cannot find organization by ObjectId. Use findOne with string type instead") diff --git a/app/security/BearerTokenAuthenticatorRepository.scala b/app/security/BearerTokenAuthenticatorRepository.scala index fcf22cef419..36c86d1674e 100644 --- a/app/security/BearerTokenAuthenticatorRepository.scala +++ b/app/security/BearerTokenAuthenticatorRepository.scala @@ -56,16 +56,20 @@ class BearerTokenAuthenticatorRepository(tokenDAO: TokenDAO)(implicit ec: Execut def add(authenticator: BearerTokenAuthenticator, tokenType: TokenType, - deleteOld: Boolean = true): Future[BearerTokenAuthenticator] = + deleteOld: Boolean = true): Future[BearerTokenAuthenticator] = { + if (deleteOld) { + removeByLoginInfoIfPresent(authenticator.loginInfo, tokenType) + } for { - oldAuthenticatorOpt <- findOneByLoginInfo(authenticator.loginInfo, tokenType) _ <- insert(authenticator, tokenType).futureBox - } yield { - if (deleteOld) { - oldAuthenticatorOpt.map(a => remove(a.id)) - } - authenticator - } + } yield authenticator + } + + private def removeByLoginInfoIfPresent(loginInfo: LoginInfo, tokenType: TokenType): Unit = + for { + oldOpt <- findOneByLoginInfo(loginInfo, tokenType) + _ = oldOpt.foreach(old => remove(old.id)) + } yield () private def insert(authenticator: BearerTokenAuthenticator, tokenType: TokenType): Fox[Unit] = for { diff --git a/app/security/CombinedAuthenticatorService.scala b/app/security/CombinedAuthenticatorService.scala index 090ba741e82..6cd0a5f3030 100644 --- a/app/security/CombinedAuthenticatorService.scala +++ b/app/security/CombinedAuthenticatorService.scala @@ -39,14 +39,14 @@ case class CombinedAuthenticatorService(cookieSettings: CookieAuthenticatorSetti private val cookieSigner = new JcaSigner(JcaSignerSettings(conf.Silhouette.CookieAuthenticator.signerSecret)) - val cookieAuthenticatorService = new CookieAuthenticatorService(cookieSettings, - None, - cookieSigner, - cookieHeaderEncoding, - new Base64AuthenticatorEncoder, - fingerprintGenerator, - idGenerator, - clock) + private val cookieAuthenticatorService = new CookieAuthenticatorService(cookieSettings, + None, + cookieSigner, + cookieHeaderEncoding, + new Base64AuthenticatorEncoder, + fingerprintGenerator, + idGenerator, + clock) val tokenAuthenticatorService = new WebknossosBearerTokenAuthenticatorService(tokenSettings, tokenDao, idGenerator, clock, userService, conf) @@ -55,9 +55,9 @@ case class CombinedAuthenticatorService(cookieSettings: CookieAuthenticatorSetti override def create(loginInfo: LoginInfo)(implicit request: RequestHeader): Future[CombinedAuthenticator] = cookieAuthenticatorService.create(loginInfo).map(CombinedAuthenticator(_)) - def createToken(loginInfo: LoginInfo): Future[CombinedAuthenticator] = { + private def createToken(loginInfo: LoginInfo): Future[CombinedAuthenticator] = { val tokenAuthenticator = tokenAuthenticatorService.create(loginInfo, TokenType.Authentication) - tokenAuthenticator.map(tokenAuthenticatorService.init(_, TokenType.Authentication)) + tokenAuthenticator.map(tokenAuthenticatorService.init(_, TokenType.Authentication, deleteOld = true)) tokenAuthenticator.map(CombinedAuthenticator(_)) } diff --git a/app/security/WebknossosBearerTokenAuthenticatorService.scala b/app/security/WebknossosBearerTokenAuthenticatorService.scala index 4ab34475baa..4a5c2c907be 100644 --- a/app/security/WebknossosBearerTokenAuthenticatorService.scala +++ b/app/security/WebknossosBearerTokenAuthenticatorService.scala @@ -54,7 +54,7 @@ class WebknossosBearerTokenAuthenticatorService(settings: BearerTokenAuthenticat } } - def init(authenticator: BearerTokenAuthenticator, tokenType: TokenType, deleteOld: Boolean = true): Future[String] = + def init(authenticator: BearerTokenAuthenticator, tokenType: TokenType, deleteOld: Boolean): Future[String] = repository .add(authenticator, tokenType, deleteOld) .map { a => @@ -67,7 +67,7 @@ class WebknossosBearerTokenAuthenticatorService(settings: BearerTokenAuthenticat def createAndInitDataStoreTokenForUser(user: User): Fox[String] = createAndInit(user.loginInfo, TokenType.DataStore, deleteOld = false) - def createAndInit(loginInfo: LoginInfo, tokenType: TokenType, deleteOld: Boolean = true): Future[String] = + def createAndInit(loginInfo: LoginInfo, tokenType: TokenType, deleteOld: Boolean): Future[String] = for { tokenAuthenticator <- create(loginInfo, tokenType) tokenId <- init(tokenAuthenticator, tokenType, deleteOld) diff --git a/conf/webknossos.versioned.routes b/conf/webknossos.versioned.routes index 3ee48062b8a..eb10f13328c 100644 --- a/conf/webknossos.versioned.routes +++ b/conf/webknossos.versioned.routes @@ -24,10 +24,7 @@ GET /v8/datasets/:organizationId/:datasetName/isValidNewName co GET /v8/datasets/:organizationId/:datasetName controllers.LegacyApiController.readDatasetV8(organizationId: String, datasetName: String, sharingToken: Option[String]) GET /v8/tasks/:id controllers.LegacyApiController.readTaskV8(id: String) POST /v8/tasks controllers.LegacyApiController.createTaskV8() -PUT /v8/tasks/:id controllers.LegacyApiController.updateTaskV8(id: String) GET /v8/projects/:id/tasks controllers.LegacyApiController.tasksForProjectV8(id: String, limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -GET /v8/annotations/:id/info controllers.LegacyApiController.annotationInfoV8(id: String, timestamp: Long) -GET /v8/tasks/:id/annotations controllers.LegacyApiController.annotationsForTaskV8(id: String) -> /v8/ webknossos.latest.Routes #v7: support changes to v9 @@ -38,10 +35,7 @@ GET /v7/datasets/:organizationId/:datasetName/isValidNewName co GET /v7/datasets/:organizationId/:datasetName controllers.LegacyApiController.readDatasetV8(organizationId: String, datasetName: String, sharingToken: Option[String]) GET /v7/tasks/:id controllers.LegacyApiController.readTaskV8(id: String) POST /v7/tasks controllers.LegacyApiController.createTaskV8() -PUT /v7/tasks/:id controllers.LegacyApiController.updateTaskV8(id: String) GET /v7/projects/:id/tasks controllers.LegacyApiController.tasksForProjectV8(id: String, limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -GET /v7/annotations/:id/info controllers.LegacyApiController.annotationInfoV8(id: String, timestamp: Long) -GET /v7/tasks/:id/annotations controllers.LegacyApiController.annotationsForTaskV8(id: String) # v7: support changes to v8 GET /v7/datasets controllers.LegacyApiController.listDatasetsV7(isActive: Option[Boolean], isUnreported: Option[Boolean], organizationName: Option[String], onlyMyOrganization: Option[Boolean], uploaderId: Option[String], folderId: Option[String], includeSubfolders: Option[Boolean], searchQuery: Option[String], limit: Option[Int], compact: Option[Boolean]) @@ -55,10 +49,7 @@ PATCH /v6/datasets/:organizationId/:datasetName/teams co GET /v6/datasets/:organizationId/:datasetName/isValidNewName controllers.LegacyApiController.isValidNewNameV8(datasetName: String, organizationId: String) GET /v6/tasks/:id controllers.LegacyApiController.readTaskV8(id: String) POST /v6/tasks controllers.LegacyApiController.createTaskV8() -PUT /v6/tasks/:id controllers.LegacyApiController.updateTaskV8(id: String) GET /v6/projects/:id/tasks controllers.LegacyApiController.tasksForProjectV8(id: String, limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -GET /v6/annotations/:id/info controllers.LegacyApiController.annotationInfoV8(id: String, timestamp: Long) -GET /v6/tasks/:id/annotations controllers.LegacyApiController.annotationsForTaskV8(id: String) # v6: support changes to v7 @@ -75,10 +66,7 @@ PATCH /v5/datasets/:organizationId/:datasetName/teams co GET /v5/datasets/:organizationId/:datasetName/isValidNewName controllers.LegacyApiController.isValidNewNameV8(datasetName: String, organizationId: String) GET /v5/tasks/:id controllers.LegacyApiController.readTaskV8(id: String) POST /v5/tasks controllers.LegacyApiController.createTaskV8() -PUT /v5/tasks/:id controllers.LegacyApiController.updateTaskV8(id: String) GET /v5/projects/:id/tasks controllers.LegacyApiController.tasksForProjectV8(id: String, limit: Option[Int], pageNumber: Option[Int], includeTotalCount: Option[Boolean]) -GET /v5/annotations/:id/info controllers.LegacyApiController.annotationInfoV8(id: String, timestamp: Long) -GET /v5/tasks/:id/annotations controllers.LegacyApiController.annotationsForTaskV8(id: String) # v5: support changes to v7 GET /v5/datasets controllers.LegacyApiController.listDatasetsV6(isActive: Option[Boolean], isUnreported: Option[Boolean], organizationName: Option[String], onlyMyOrganization: Option[Boolean], uploaderId: Option[String], folderId: Option[String], includeSubfolders: Option[Boolean], searchQuery: Option[String], limit: Option[Int], compact: Option[Boolean]) diff --git a/docs/ui/layers.md b/docs/ui/layers.md index f7f1dd55064..7edbe00411f 100644 --- a/docs/ui/layers.md +++ b/docs/ui/layers.md @@ -31,6 +31,7 @@ In addition to the general layer properties mentioned above, `color` and `segmen - `Color`: Every `color` layer can be re-colored to make it easily identifiable. By default, all layers have a white overlay, showing the true, raw black & white data. Clicking on the square color indicator brings up your system's color palette to choose from. Note, there is an icon button for inverting all color values in this layer. - `Pattern Opacity`: Adjust the visibility of the texture/pattern on each segment. To make segments easier to distinguish and more unique, a pattern is applied to each in addition to its base color. 0% hides the pattern. 100% makes the pattern very prominent. Great for increasing the visual contrast between segments. +- `Selective Visibility`: When activated, only segments are shown that are currently active or hovered. - `ID Mapping`: WEBKNOSSOS supports applying pre-computed agglomerations/groupings of segmentation IDs for a given segmentation layer. This is a very powerful feature to explore and compare different segmentation strategies for a given segmentation layer. Mappings need to be pre-computed and stored together with a dataset for WEBKNOSSOS to download and apply. [Read more about this here](../proofreading/segmentation_mappings.md). diff --git a/docs/volume_annotation/images/icon_restricted_floodfill.jpg b/docs/volume_annotation/images/icon_restricted_floodfill.jpg new file mode 100644 index 00000000000..bb7e1b21599 Binary files /dev/null and b/docs/volume_annotation/images/icon_restricted_floodfill.jpg differ diff --git a/docs/volume_annotation/tools.md b/docs/volume_annotation/tools.md index bf8a8f40dcd..4b24f0dbdf2 100644 --- a/docs/volume_annotation/tools.md +++ b/docs/volume_annotation/tools.md @@ -68,6 +68,8 @@ The following interactions and modes become available when working with some of ![3D Fill Modifier](./images/3d-modifier.jpg){align=left width="60"} **2D/3D Fill**: Modifies the flood filling tool to work in 2D (in-plane only) or 3D (volumetric fill/re-labeling). 3D flood fill is constrained to a small, regional bounding box for performance reasons. Read more about [flood fills](#volume-flood-fills) below. +![Restrict Fill](./images/icon_restricted_floodfill.jpg){align=left width="60"} +**Restrict Fill by Bounding Box**: When enabled, the fill operation will be restricted by the smallest bounding box that encloses the clicked position. This feature can be useful when correcting segmentation in a small bounding box (e.g., when curating training data). ## Quick-select tool The Quick Select tool offers AI-powered automatic segmentation, powered by [Segment Anything Model 2](https://ai.meta.com/blog/segment-anything-2/). Simply draw a selection around your target structure, and WEBKNOSSOS will automatically segment it for you. diff --git a/frontend/javascripts/admin/voxelytics/ai_model_list_view.tsx b/frontend/javascripts/admin/voxelytics/ai_model_list_view.tsx index 7962e955660..83b34bdc296 100644 --- a/frontend/javascripts/admin/voxelytics/ai_model_list_view.tsx +++ b/frontend/javascripts/admin/voxelytics/ai_model_list_view.tsx @@ -109,7 +109,7 @@ function TrainNewAiJobModal({ onClose }: { onClose: () => void }) { AnnotationInfoForAIJob[] >([]); - const getMagForSegmentationLayer = async (annotationId: string, layerName: string) => { + const getMagsForSegmentationLayer = (annotationId: string, layerName: string) => { // The layer name is a human-readable one. It can either belong to an annotationLayer // (therefore, also to a volume tracing) or to the actual dataset. // Both are checked below. This won't be ambiguous because annotationLayers must not @@ -130,10 +130,10 @@ function TrainNewAiJobModal({ onClose }: { onClose: () => void }) { (tracing) => tracing.tracingId === annotationLayer.tracingId, ); const mags = volumeTracingMags[volumeTracingIndex] || ([[1, 1, 1]] as Vector3[]); - return getMagInfo(mags).getFinestMag(); + return getMagInfo(mags); } else { const segmentationLayer = getSegmentationLayerByName(dataset, layerName); - return getMagInfo(segmentationLayer.resolutions).getFinestMag(); + return getMagInfo(segmentationLayer.resolutions); } }; @@ -152,7 +152,7 @@ function TrainNewAiJobModal({ onClose }: { onClose: () => void }) { maskClosable={false} > { diff --git a/frontend/javascripts/components/async_clickables.tsx b/frontend/javascripts/components/async_clickables.tsx index 9bbafae969d..1b63ccb17c7 100644 --- a/frontend/javascripts/components/async_clickables.tsx +++ b/frontend/javascripts/components/async_clickables.tsx @@ -1,4 +1,4 @@ -import { Button, type ButtonProps } from "antd"; +import { Button, ConfigProvider, type ButtonProps } from "antd"; import { LoadingOutlined } from "@ant-design/icons"; import * as React from "react"; import FastTooltip from "./fast_tooltip"; @@ -47,9 +47,12 @@ export function AsyncButton(props: AsyncButtonProps) { const effectiveChildren = hideContentWhenLoading && isLoading ? null : children; return ( - + {/* Avoid weird animation when icons swap */} + + + ); } diff --git a/frontend/javascripts/components/fast_tooltip.tsx b/frontend/javascripts/components/fast_tooltip.tsx index cb31751b8fc..d80d2ddd49c 100644 --- a/frontend/javascripts/components/fast_tooltip.tsx +++ b/frontend/javascripts/components/fast_tooltip.tsx @@ -32,7 +32,7 @@ import { Tooltip as ReactTooltip } from "react-tooltip"; const ROOT_TOOLTIP_IDS = { DEFAULT: "main-tooltip", DYNAMIC: "main-tooltip-dynamic", -}; +} as const; export type FastTooltipPlacement = | "top" diff --git a/frontend/javascripts/components/layer_selection.tsx b/frontend/javascripts/components/layer_selection.tsx index af8555c6cd7..688cd43c2de 100644 --- a/frontend/javascripts/components/layer_selection.tsx +++ b/frontend/javascripts/components/layer_selection.tsx @@ -8,6 +8,7 @@ type LayerSelectionProps = { getReadableNameForLayer: (layer: L) => string; fixedLayerName?: string; label?: string; + onChange?: (a: string) => void; }; export function LayerSelection({ @@ -65,6 +66,7 @@ export function LayerSelectionFormItem({ getReadableNameForLayer, fixedLayerName, label, + onChange, }: LayerSelectionProps): JSX.Element { const layerType = chooseSegmentationLayer ? "segmentation" : "color"; return ( @@ -85,6 +87,7 @@ export function LayerSelectionFormItem({ fixedLayerName={fixedLayerName} layerType={layerType} getReadableNameForLayer={getReadableNameForLayer} + onChange={onChange} /> ); diff --git a/frontend/javascripts/components/mag_selection.tsx b/frontend/javascripts/components/mag_selection.tsx new file mode 100644 index 00000000000..965a6848e03 --- /dev/null +++ b/frontend/javascripts/components/mag_selection.tsx @@ -0,0 +1,73 @@ +import { Form, Select } from "antd"; +import { V3 } from "libs/mjs"; +import { clamp } from "libs/utils"; +import type { Vector3 } from "oxalis/constants"; +import type { MagInfo } from "oxalis/model/helpers/mag_info"; + +export function MagSelectionFormItem({ + name, + magInfo, +}: { + name: string | Array; + magInfo: MagInfo | undefined; +}): JSX.Element { + return ( + + + + ); +} + +function MagSelection({ + magInfo, + value, + onChange, +}: { + magInfo: MagInfo | undefined; + value?: Vector3; + onChange?: (newValue: Vector3) => void; +}): JSX.Element { + const allMags = magInfo != null ? magInfo.getMagList() : []; + + const onSelect = (index: number | undefined) => { + if (onChange == null || index == null) return; + const newMag = allMags[index]; + if (newMag != null) onChange(newMag); + }; + + return ( + + ); +} diff --git a/frontend/javascripts/libs/input.ts b/frontend/javascripts/libs/input.ts index cd3d385e518..daa55d0a1d9 100644 --- a/frontend/javascripts/libs/input.ts +++ b/frontend/javascripts/libs/input.ts @@ -340,7 +340,7 @@ export class InputKeyboard { } // The mouse module. -// Events: over, out, leftClick, rightClick, leftDownMove +// Events: over, out, {left,right}Click, {left,right}DownMove, leftDoubleClick class InputMouseButton { mouse: InputMouse; name: MouseButtonString; @@ -393,6 +393,19 @@ class InputMouseButton { } } + handleDoubleClick(event: MouseEvent, triggeredByTouch: boolean): void { + // DoubleClick is only supported for the left mouse button + if (this.name === "left" && this.moveDelta <= MOUSE_MOVE_DELTA_THRESHOLD) { + this.mouse.emitter.emit( + "leftDoubleClick", + this.mouse.lastPosition, + this.id, + event, + triggeredByTouch, + ); + } + } + handleMouseMove(event: MouseEvent, delta: Point2): void { if (this.down) { this.moveDelta += Math.abs(delta.x) + Math.abs(delta.y); @@ -446,6 +459,7 @@ export class InputMouse { document.addEventListener("mousemove", this.mouseMove); document.addEventListener("mouseup", this.mouseUp); document.addEventListener("touchend", this.touchEnd); + document.addEventListener("dblclick", this.doubleClick); this.delegatedEvents = { ...Utils.addEventListenerWithDelegation( @@ -498,6 +512,7 @@ export class InputMouse { document.removeEventListener("mousemove", this.mouseMove); document.removeEventListener("mouseup", this.mouseUp); document.removeEventListener("touchend", this.touchEnd); + document.removeEventListener("dblclick", this.doubleClick); for (const [eventName, eventHandler] of Object.entries(this.delegatedEvents)) { document.removeEventListener(eventName, eventHandler); @@ -551,6 +566,12 @@ export class InputMouse { } }; + doubleClick = (event: MouseEvent): void => { + if (this.isHit(event)) { + this.leftMouseButton.handleDoubleClick(event, false); + } + }; + touchEnd = (): void => { // The order of events during a click on a touch enabled device is: // touch events -> mouse events -> click diff --git a/frontend/javascripts/libs/mjs.ts b/frontend/javascripts/libs/mjs.ts index 4c5b6db8a43..4588fc10f63 100644 --- a/frontend/javascripts/libs/mjs.ts +++ b/frontend/javascripts/libs/mjs.ts @@ -250,6 +250,9 @@ const V2 = { clone(a: Vector2): Vector2 { return [a[0], a[1]]; }, + prod(a: Vector2) { + return a[0] * a[1]; + }, }; const _tmpVec: Vector3 = [0, 0, 0]; diff --git a/frontend/javascripts/libs/progress_callback.ts b/frontend/javascripts/libs/progress_callback.ts index 49cd3aacef7..b9e6f7d60bd 100644 --- a/frontend/javascripts/libs/progress_callback.ts +++ b/frontend/javascripts/libs/progress_callback.ts @@ -1,6 +1,8 @@ import { message } from "antd"; import { sleep } from "libs/utils"; + type HideFn = () => void; + export type ProgressCallback = ( isDone: boolean, progressState: string | React.ReactNode, @@ -9,11 +11,14 @@ export type ProgressCallback = ( ) => Promise<{ hideFn: HideFn; }>; + type Options = { pauseDelay: number; successMessageDelay: number; key?: string; -}; // This function returns another function which can be called within a longer running +}; + +// This function returns another function which can be called within a longer running // process to update the UI with progress information. Example usage: // const progressCallback = createProgressCallback({ pauseDelay: 100, successMessageDelay: 5000 }); // await progressCallback(false, "Beginning work...") diff --git a/frontend/javascripts/navbar.tsx b/frontend/javascripts/navbar.tsx index 12873f12c91..c3169385ea9 100644 --- a/frontend/javascripts/navbar.tsx +++ b/frontend/javascripts/navbar.tsx @@ -983,16 +983,17 @@ function Navbar({ paddingTop: navbarHeight > constants.DEFAULT_NAVBAR_HEIGHT ? constants.BANNER_HEIGHT : 0, }} /> - -
- {trailingNavItems} -
+ +
+ {trailingNavItems} +
+
); } diff --git a/frontend/javascripts/oxalis/constants.ts b/frontend/javascripts/oxalis/constants.ts index 3276804c564..14c1715ac5d 100644 --- a/frontend/javascripts/oxalis/constants.ts +++ b/frontend/javascripts/oxalis/constants.ts @@ -333,6 +333,9 @@ const Constants = { _2D: (process.env.IS_TESTING ? [512, 512, 1] : [768, 768, 1]) as Vector3, _3D: (process.env.IS_TESTING ? [64, 64, 32] : [96, 96, 96]) as Vector3, }, + // When the user uses the "isFloodfillRestrictedToBoundingBox" setting, + // we are more lax with the flood fill extent. + FLOOD_FILL_MULTIPLIER_FOR_BBOX_RESTRICTION: 10, MAXIMUM_DATE_TIMESTAMP: 8640000000000000, SCALEBAR_HEIGHT: 22, SCALEBAR_OFFSET: 10, diff --git a/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts index 8bba7bd64c9..ea389aaeec3 100644 --- a/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts +++ b/frontend/javascripts/oxalis/controller/combinations/bounding_box_handlers.ts @@ -242,7 +242,9 @@ export function createBoundingBoxAndGetEdges( addUserBoundingBoxAction({ boundingBox: { min: globalPosition, - max: V3.add(globalPosition, [1, 1, 1]), + // The last argument ensures that a Vector3 is used and not a + // Float32Array. + max: V3.add(globalPosition, [1, 1, 1], [0, 0, 0]), }, }), ); diff --git a/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts b/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts index 811f6c40f49..dc9c461de42 100644 --- a/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts +++ b/frontend/javascripts/oxalis/controller/combinations/tool_controls.ts @@ -137,6 +137,20 @@ export class MoveTool { } handleClickSegment(pos); }, + leftDoubleClick: (pos: Point2, _plane: OrthoView, _event: MouseEvent, _isTouch: boolean) => { + const { uiInformation } = Store.getState(); + const isMoveToolActive = uiInformation.activeTool === AnnotationToolEnum.MOVE; + + if (isMoveToolActive) { + // We want to select the clicked segment ID only in the MOVE tool. This method is + // implemented within the Move tool, but other tool controls will fall back to this one + // if they didn't define the double click hook. However, for most other tools, this behavior + // would be suboptimal, because when doing a double click, the first click will also be registered + // as a simple left click. For example, doing a double click with the brush tool would brush something + // and then immediately select the id again which is weird. + VolumeHandlers.handlePickCell(pos); + } + }, middleClick: (pos: Point2, _plane: OrthoView, event: MouseEvent) => { if (event.shiftKey) { handleAgglomerateSkeletonAtClick(pos); diff --git a/frontend/javascripts/oxalis/default_state.ts b/frontend/javascripts/oxalis/default_state.ts index da524d26c4f..57fd1c860b1 100644 --- a/frontend/javascripts/oxalis/default_state.ts +++ b/frontend/javascripts/oxalis/default_state.ts @@ -84,6 +84,7 @@ const defaultState: OxalisState = { gpuMemoryFactor: Constants.DEFAULT_GPU_MEMORY_FACTOR, overwriteMode: OverwriteModeEnum.OVERWRITE_ALL, fillMode: FillModeEnum._2D, + isFloodfillRestrictedToBoundingBox: false, interpolationMode: InterpolationModeEnum.INTERPOLATE, useLegacyBindings: false, quickSelect: { @@ -142,7 +143,6 @@ const defaultState: OxalisState = { dataStore: { name: "localhost", url: "http://localhost:9000", - isScratch: false, allowsUpload: true, jobsEnabled: false, jobsSupportedByAvailableWorkers: [], diff --git a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts index 87c5a6220c7..aabec1e2429 100644 --- a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts +++ b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts @@ -146,6 +146,9 @@ class PlaneMaterialFactory { selectiveVisibilityInProofreading: { value: true, }, + selectiveSegmentVisibility: { + value: false, + }, is3DViewBeingRendered: { value: true, }, @@ -562,6 +565,17 @@ class PlaneMaterialFactory { true, ), ); + + this.storePropertyUnsubscribers.push( + listenToStoreProperty( + (storeState) => storeState.datasetConfiguration.selectiveSegmentVisibility, + (selectiveSegmentVisibility) => { + this.uniforms.selectiveSegmentVisibility.value = selectiveSegmentVisibility; + }, + true, + ), + ); + this.storePropertyUnsubscribers.push( listenToStoreProperty( (storeState) => getMagInfoByLayer(storeState.dataset), diff --git a/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts b/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts index fc559dedd61..3cc0b15b089 100644 --- a/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/tracing_accessor.ts @@ -10,6 +10,8 @@ import type { import type { ServerTracing, TracingType } from "types/api_flow_types"; import { TracingTypeEnum } from "types/api_flow_types"; import type { SaveQueueType } from "oxalis/model/actions/save_actions"; +import BoundingBox from "../bucket_data_handling/bounding_box"; +import type { Vector3 } from "oxalis/constants"; export function maybeGetSomeTracing( tracing: Tracing, @@ -86,7 +88,17 @@ export function selectTracing( return tracing; } + export const getUserBoundingBoxesFromState = (state: OxalisState): Array => { const maybeSomeTracing = maybeGetSomeTracing(state.tracing); return maybeSomeTracing != null ? maybeSomeTracing.userBoundingBoxes : []; }; + +export const getUserBoundingBoxesThatContainPosition = ( + state: OxalisState, + position: Vector3, +): Array => { + const bboxes = getUserBoundingBoxesFromState(state); + + return bboxes.filter((el) => new BoundingBox(el.boundingBox).containsPoint(position)); +}; diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index caf749eeb3e..9860f2b89f2 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -14,7 +14,7 @@ export type InitializeEditableMappingAction = ReturnType; type StartEditingAction = ReturnType; type AddToLayerAction = ReturnType; -type FloodFillAction = ReturnType; +export type FloodFillAction = ReturnType; export type PerformMinCutAction = ReturnType; type FinishEditingAction = ReturnType; export type SetActiveCellAction = ReturnType; diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts index 94adfbfea5f..6bc025d7b67 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/bucket.ts @@ -39,6 +39,10 @@ const warnMergeWithoutPendingOperations = _.throttle(() => { ); }, WARNING_THROTTLE_THRESHOLD); +const warnAwaitedMissingBucket = _.throttle(() => { + ErrorHandling.notify(new Error("Awaited missing bucket")); +}, WARNING_THROTTLE_THRESHOLD); + export function assertNonNullBucket(bucket: Bucket): asserts bucket is DataBucket { if (bucket.type === "null") { throw new Error("Unexpected null bucket."); @@ -773,7 +777,7 @@ export class DataBucket { // In the past, ensureLoaded() never returned if the bucket // was MISSING. This log might help to discover potential // bugs which could arise in combination with MISSING buckets. - console.warn("Awaited missing bucket."); + warnAwaitedMissingBucket(); } } } diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts index 479d8962888..1df073770d6 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/data_cube.ts @@ -487,7 +487,7 @@ class DataCube { additionalCoordinates: AdditionalCoordinate[] | null, segmentIdNumber: number, dimensionIndices: DimensionMap, - floodfillBoundingBox: BoundingBoxType, + _floodfillBoundingBox: BoundingBoxType, zoomStep: number, progressCallback: ProgressCallback, use3D: boolean, @@ -498,14 +498,17 @@ class DataCube { }> { // This flood-fill algorithm works in two nested levels and uses a list of buckets to flood fill. // On the inner level a bucket is flood-filled and if the iteration of the buckets data - // reaches an neighbour bucket, this bucket is added to this list of buckets to flood fill. + // reaches a neighbour bucket, this bucket is added to this list of buckets to flood fill. // The outer level simply iterates over all buckets in the list and triggers the bucket-wise flood fill. // Additionally a map is created that saves all labeled voxels for each bucket. This map is returned at the end. // - // Note: It is possible that a bucket is multiple times added to the list of buckets. This is intended + // Note: It is possible that a bucket is added multiple times to the list of buckets. This is intended // because a border of the "neighbour volume shape" might leave the neighbour bucket and enter it somewhere else. // If it would not be possible to have the same neighbour bucket in the list multiple times, // not all of the target area in the neighbour bucket might be filled. + + const floodfillBoundingBox = new BoundingBox(_floodfillBoundingBox); + // Helper function to convert between xyz and uvw (both directions) const transpose = (voxel: Vector3): Vector3 => Dimensions.transDimWithIndices(voxel, dimensionIndices); @@ -517,12 +520,12 @@ class DataCube { zoomStep, ); const seedBucket = this.getOrCreateBucket(seedBucketAddress); - let coveredBBoxMin: Vector3 = [ + const coveredBBoxMin: Vector3 = [ Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, ]; - let coveredBBoxMax: Vector3 = [0, 0, 0]; + const coveredBBoxMax: Vector3 = [0, 0, 0]; if (seedBucket.type === "null") { return { @@ -689,35 +692,37 @@ class DataCube { } else { // Label the current neighbour and add it to the neighbourVoxelStackUvw to iterate over its neighbours. const neighbourVoxelIndex = this.getVoxelIndexByVoxelOffset(neighbourVoxelXyz); + const currentGlobalPosition = V3.add( + currentGlobalBucketPosition, + V3.scale3(adjustedNeighbourVoxelXyz, currentMag), + ); if (bucketData[neighbourVoxelIndex] === sourceSegmentId) { - bucketData[neighbourVoxelIndex] = segmentId; - markUvwInSliceAsLabeled(neighbourVoxelUvw); - neighbourVoxelStackUvw.pushVoxel(neighbourVoxelUvw); - labeledVoxelCount++; - const currentGlobalPosition = V3.add( - currentGlobalBucketPosition, - V3.scale3(adjustedNeighbourVoxelXyz, currentMag), - ); - coveredBBoxMin = [ - Math.min(coveredBBoxMin[0], currentGlobalPosition[0]), - Math.min(coveredBBoxMin[1], currentGlobalPosition[1]), - Math.min(coveredBBoxMin[2], currentGlobalPosition[2]), - ]; - // The maximum is exclusive which is why we add 1 to the position - coveredBBoxMax = [ - Math.max(coveredBBoxMax[0], currentGlobalPosition[0] + 1), - Math.max(coveredBBoxMax[1], currentGlobalPosition[1] + 1), - Math.max(coveredBBoxMax[2], currentGlobalPosition[2] + 1), - ]; - - if (labeledVoxelCount % 1000000 === 0) { - console.log(`Labeled ${labeledVoxelCount} Vx. Continuing...`); - - await progressCallback( - false, - `Labeled ${labeledVoxelCount / 1000000} MVx. Continuing...`, - ); + if (floodfillBoundingBox.containsPoint(currentGlobalPosition)) { + bucketData[neighbourVoxelIndex] = segmentId; + markUvwInSliceAsLabeled(neighbourVoxelUvw); + neighbourVoxelStackUvw.pushVoxel(neighbourVoxelUvw); + labeledVoxelCount++; + + coveredBBoxMin[0] = Math.min(coveredBBoxMin[0], currentGlobalPosition[0]); + coveredBBoxMin[1] = Math.min(coveredBBoxMin[1], currentGlobalPosition[1]); + coveredBBoxMin[2] = Math.min(coveredBBoxMin[2], currentGlobalPosition[2]); + + // The maximum is exclusive which is why we add 1 to the position + coveredBBoxMax[0] = Math.max(coveredBBoxMax[0], currentGlobalPosition[0] + 1); + coveredBBoxMax[1] = Math.max(coveredBBoxMax[1], currentGlobalPosition[1] + 1); + coveredBBoxMax[2] = Math.max(coveredBBoxMax[2], currentGlobalPosition[2] + 1); + + if (labeledVoxelCount % 1000000 === 0) { + console.log(`Labeled ${labeledVoxelCount} Vx. Continuing...`); + + await progressCallback( + false, + `Labeled ${labeledVoxelCount / 1000000} MVx. Continuing...`, + ); + } + } else { + wasBoundingBoxExceeded = true; } } } diff --git a/frontend/javascripts/oxalis/model/sagas/volume/floodfill_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volume/floodfill_saga.tsx new file mode 100644 index 00000000000..dd1d17b692e --- /dev/null +++ b/frontend/javascripts/oxalis/model/sagas/volume/floodfill_saga.tsx @@ -0,0 +1,356 @@ +import { V2, V3 } from "libs/mjs"; +import createProgressCallback, { type ProgressCallback } from "libs/progress_callback"; +import Toast from "libs/toast"; +import * as Utils from "libs/utils"; +import type { + BoundingBoxType, + LabeledVoxelsMap, + OrthoView, + Vector2, + Vector3, + FillMode, +} from "oxalis/constants"; +import Constants, { FillModeEnum, Unicode } from "oxalis/constants"; + +import { getDatasetBoundingBox, getMagInfo } from "oxalis/model/accessors/dataset_accessor"; +import { getActiveMagIndexForLayer } from "oxalis/model/accessors/flycam_accessor"; +import { enforceActiveVolumeTracing } from "oxalis/model/accessors/volumetracing_accessor"; +import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; +import { setBusyBlockingInfoAction } from "oxalis/model/actions/ui_actions"; +import { + finishAnnotationStrokeAction, + type FloodFillAction, + updateSegmentAction, +} from "oxalis/model/actions/volumetracing_actions"; +import BoundingBox from "oxalis/model/bucket_data_handling/bounding_box"; +import Dimensions from "oxalis/model/dimensions"; +import type { Saga } from "oxalis/model/sagas/effect-generators"; +import { select, take } from "oxalis/model/sagas/effect-generators"; +import { requestBucketModificationInVolumeTracing } from "oxalis/model/sagas/saga_helpers"; +import { Model } from "oxalis/singletons"; +import { call, put, takeEvery } from "typed-redux-saga"; +import { getUserBoundingBoxesThatContainPosition } from "../../accessors/tracing_accessor"; +import { applyLabeledVoxelMapToAllMissingMags } from "./helpers"; +import _ from "lodash"; + +const NO_FLOODFILL_BBOX_TOAST_KEY = "NO_FLOODFILL_BBOX"; +const NO_SUCCESS_MSG_WHEN_WITHIN_MS = 500; + +export function* floodFill(): Saga { + yield* take("INITIALIZE_VOLUMETRACING"); + yield* takeEvery("FLOOD_FILL", handleFloodFill); +} + +function* getBoundingBoxForFloodFillWhenRestricted(position: Vector3, currentViewport: OrthoView) { + const fillMode = yield* select((state) => state.userConfiguration.fillMode); + const bboxes = yield* select((state) => getUserBoundingBoxesThatContainPosition(state, position)); + if (bboxes.length === 0) { + return { + failureReason: + "No bounding box encloses the clicked position. Either disable the bounding box restriction or ensure a bounding box exists around the clicked position.", + }; + } + const smallestBbox = _.sortBy(bboxes, (bbox) => new BoundingBox(bbox.boundingBox).getVolume())[0]; + + const maximumVoxelSize = + Constants.FLOOD_FILL_MULTIPLIER_FOR_BBOX_RESTRICTION * + V3.prod(Constants.FLOOD_FILL_EXTENTS[fillMode]); + const bboxObj = new BoundingBox(smallestBbox.boundingBox); + + const bboxVolume = + fillMode === FillModeEnum._3D + ? bboxObj.getVolume() + : // Only consider the 2D projection of the bounding box onto the current viewport + V2.prod( + Dimensions.getIndices(currentViewport).map((idx) => bboxObj.getSize()[idx]) as Vector2, + ); + if (bboxVolume > maximumVoxelSize) { + return { + failureReason: `The bounding box that encloses the clicked position is too large. Shrink its size so that it does not contain more than ${maximumVoxelSize} voxels.`, + }; + } + return smallestBbox.boundingBox; +} + +function* getBoundingBoxForFloodFillWhenUnrestricted( + position: Vector3, + currentViewport: OrthoView, +) { + const fillMode = yield* select((state) => state.userConfiguration.fillMode); + const halfBoundingBoxSizeUVW = V3.scale(Constants.FLOOD_FILL_EXTENTS[fillMode], 0.5); + const currentViewportBounding = { + min: V3.sub(position, halfBoundingBoxSizeUVW), + max: V3.add(position, halfBoundingBoxSizeUVW), + }; + + if (fillMode === FillModeEnum._2D) { + // Only use current plane + const thirdDimension = Dimensions.thirdDimensionForPlane(currentViewport); + const numberOfSlices = 1; + currentViewportBounding.min[thirdDimension] = position[thirdDimension]; + currentViewportBounding.max[thirdDimension] = position[thirdDimension] + numberOfSlices; + } + + const datasetBoundingBox = yield* select((state) => getDatasetBoundingBox(state.dataset)); + const { min: clippedMin, max: clippedMax } = new BoundingBox( + currentViewportBounding, + ).intersectedWith(datasetBoundingBox); + return { + min: clippedMin, + max: clippedMax, + }; +} + +function* getBoundingBoxForFloodFill( + position: Vector3, + currentViewport: OrthoView, +): Saga { + const isRestrictedToBoundingBox = yield* select( + (state) => state.userConfiguration.isFloodfillRestrictedToBoundingBox, + ); + if (isRestrictedToBoundingBox) { + return yield* call(getBoundingBoxForFloodFillWhenRestricted, position, currentViewport); + } else { + return yield* call(getBoundingBoxForFloodFillWhenUnrestricted, position, currentViewport); + } +} + +function* handleFloodFill(floodFillAction: FloodFillAction): Saga { + const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate); + + if (!allowUpdate) { + return; + } + + const { position: positionFloat, planeId } = floodFillAction; + const volumeTracing = yield* select(enforceActiveVolumeTracing); + if (volumeTracing.hasEditableMapping) { + const message = "Volume modification is not allowed when an editable mapping is active."; + Toast.error(message); + console.error(message); + return; + } + const segmentationLayer = yield* call( + [Model, Model.getSegmentationTracingLayer], + volumeTracing.tracingId, + ); + const { cube } = segmentationLayer; + const seedPosition = Dimensions.roundCoordinate(positionFloat); + const activeCellId = volumeTracing.activeCellId; + const dimensionIndices = Dimensions.getIndices(planeId); + const requestedZoomStep = yield* select((state) => + getActiveMagIndexForLayer(state, segmentationLayer.name), + ); + const magInfo = yield* call(getMagInfo, segmentationLayer.mags); + const labeledZoomStep = magInfo.getClosestExistingIndex(requestedZoomStep); + const additionalCoordinates = yield* select((state) => state.flycam.additionalCoordinates); + const oldSegmentIdAtSeed = cube.getDataValue( + seedPosition, + additionalCoordinates, + null, + labeledZoomStep, + ); + + if (activeCellId === oldSegmentIdAtSeed) { + Toast.warning("The clicked voxel's id is already equal to the active segment id."); + return; + } + + const busyBlockingInfo = yield* select((state) => state.uiInformation.busyBlockingInfo); + + if (busyBlockingInfo.isBusy) { + console.warn(`Ignoring floodfill request (reason: ${busyBlockingInfo.reason || "unknown"})`); + return; + } + // As the flood fill will be applied to the volume layer, + // the potentially existing mapping should be locked to ensure a consistent state. + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, + volumeTracing, + ); + if (!isModificationAllowed) { + return; + } + const boundingBoxForFloodFill = yield* call(getBoundingBoxForFloodFill, seedPosition, planeId); + if ("failureReason" in boundingBoxForFloodFill) { + Toast.warning(boundingBoxForFloodFill.failureReason, { + key: NO_FLOODFILL_BBOX_TOAST_KEY, + }); + return; + } else { + Toast.close(NO_FLOODFILL_BBOX_TOAST_KEY); + } + yield* put(setBusyBlockingInfoAction(true, "Floodfill is being computed.")); + const progressCallback = createProgressCallback({ + pauseDelay: 200, + successMessageDelay: 2000, + }); + yield* call(progressCallback, false, "Performing floodfill..."); + console.time("cube.floodFill"); + const startTimeOfFloodfill = performance.now(); + const fillMode = yield* select((state) => state.userConfiguration.fillMode); + + const { + bucketsWithLabeledVoxelsMap: labelMasksByBucketAndW, + wasBoundingBoxExceeded, + coveredBoundingBox, + } = yield* call( + { context: cube, fn: cube.floodFill }, + seedPosition, + additionalCoordinates, + activeCellId, + dimensionIndices, + boundingBoxForFloodFill, + labeledZoomStep, + progressCallback, + fillMode === FillModeEnum._3D, + ); + console.timeEnd("cube.floodFill"); + yield* call(progressCallback, false, "Finalizing floodfill..."); + const indexSet: Set = new Set(); + + for (const labelMaskByIndex of labelMasksByBucketAndW.values()) { + for (const zIndex of labelMaskByIndex.keys()) { + indexSet.add(zIndex); + } + } + + console.time("applyLabeledVoxelMapToAllMissingMags"); + + for (const indexZ of indexSet) { + const labeledVoxelMapFromFloodFill: LabeledVoxelsMap = new Map(); + + for (const [bucketAddress, labelMaskByIndex] of labelMasksByBucketAndW.entries()) { + const map = labelMaskByIndex.get(indexZ); + + if (map != null) { + labeledVoxelMapFromFloodFill.set(bucketAddress, map); + } + } + + applyLabeledVoxelMapToAllMissingMags( + labeledVoxelMapFromFloodFill, + labeledZoomStep, + dimensionIndices, + magInfo, + cube, + activeCellId, + indexZ, + true, + ); + } + + yield* put(finishAnnotationStrokeAction(volumeTracing.tracingId)); + yield* put( + updateSegmentAction( + volumeTracing.activeCellId, + { + somePosition: seedPosition, + someAdditionalCoordinates: additionalCoordinates || undefined, + }, + volumeTracing.tracingId, + ), + ); + + console.timeEnd("applyLabeledVoxelMapToAllMissingMags"); + + yield* call( + notifyUserAboutResult, + wasBoundingBoxExceeded, + startTimeOfFloodfill, + progressCallback, + fillMode, + coveredBoundingBox, + oldSegmentIdAtSeed, + activeCellId, + seedPosition, + ); + + cube.triggerPushQueue(); + yield* put(setBusyBlockingInfoAction(false)); + + if (floodFillAction.callback != null) { + floodFillAction.callback(); + } +} + +function* notifyUserAboutResult( + wasBoundingBoxExceeded: boolean, + startTimeOfFloodfill: number, + progressCallback: ProgressCallback, + fillMode: FillMode, + coveredBoundingBox: BoundingBoxType, + oldSegmentIdAtSeed: number, + activeCellId: number, + seedPosition: Vector3, +) { + let showSuccessMsg = false; + if (wasBoundingBoxExceeded) { + const isRestrictedToBoundingBox = yield* select( + (state) => state.userConfiguration.isFloodfillRestrictedToBoundingBox, + ); + // Don't notify the user about early-terminated floodfills if the floodfill + // was configured to be restricted, anyway. Also, don't create a new bounding + // box in that case. + if (!isRestrictedToBoundingBox) { + // The bounding box is overkill for the 2D mode because in that case, + // it's trivial to check the borders manually. + const createNewBoundingBox = fillMode === FillModeEnum._3D; + const warningDetails = createNewBoundingBox + ? "A bounding box that represents the labeled volume was added so that you can check the borders manually." + : "Please check the borders of the filled area manually and use the fill tool again if necessary."; + + // Pre-declare a variable for the hide function so that we can refer + // to that var within the toast content. We don't want to use message.destroy + // because this ignores the setTimeout within the progress callback utility. + // Without this, hide functions for older toasts could still be triggered (due to + // timeout) that act on new ones then. + let hideBox: { hideFn: () => void } | undefined; + hideBox = yield* call( + progressCallback, + true, + <> + Floodfill is done, but terminated because{" "} + {isRestrictedToBoundingBox + ? "the labeled volume touched the bounding box to which the floodfill was restricted" + : "the labeled volume got too large"} + . +
+ {warningDetails} {Unicode.NonBreakingSpace} + hideBox?.hideFn()}> + Close + + , + { + successMessageDelay: 10000, + }, + ); + if (createNewBoundingBox) { + yield* put( + addUserBoundingBoxAction({ + boundingBox: coveredBoundingBox, + name: `Limits of flood-fill (source_id=${oldSegmentIdAtSeed}, target_id=${activeCellId}, seed=${seedPosition.join( + ",", + )}, timestamp=${new Date().getTime()})`, + color: Utils.getRandomColor(), + isVisible: true, + }), + ); + } + } else { + showSuccessMsg = true; + } + } else { + showSuccessMsg = true; + } + + const floodfillDuration = performance.now() - startTimeOfFloodfill; + const wasFloodfillQuick = floodfillDuration < NO_SUCCESS_MSG_WHEN_WITHIN_MS; + + if (showSuccessMsg) { + const { hideFn } = yield* call(progressCallback, true, "Floodfill done."); + if (wasFloodfillQuick) { + hideFn(); + } + } +} diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx index 12e1919ac31..463e2ea4c13 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx @@ -1,41 +1,27 @@ -import { message } from "antd"; import { diffDiffableMaps } from "libs/diffable_map"; import { V3 } from "libs/mjs"; -import createProgressCallback from "libs/progress_callback"; import Toast from "libs/toast"; -import * as Utils from "libs/utils"; import _ from "lodash"; import memoizeOne from "memoize-one"; import type { AnnotationTool, - BoundingBoxType, ContourMode, - LabeledVoxelsMap, OrthoView, OverwriteMode, Vector3, } from "oxalis/constants"; -import Constants, { +import { AnnotationToolEnum, ContourModeEnum, - FillModeEnum, OrthoViews, - Unicode, OverwriteModeEnum, } from "oxalis/constants"; import getSceneController from "oxalis/controller/scene_controller_provider"; import { CONTOUR_COLOR_DELETE, CONTOUR_COLOR_NORMAL } from "oxalis/geometries/helper_geometries"; -import { - getDatasetBoundingBox, - getMaximumSegmentIdForLayer, - getMagInfo, -} from "oxalis/model/accessors/dataset_accessor"; -import { - getPosition, - getActiveMagIndexForLayer, - getRotation, -} from "oxalis/model/accessors/flycam_accessor"; +import messages from "messages"; +import { getMaximumSegmentIdForLayer } from "oxalis/model/accessors/dataset_accessor"; +import { getPosition, getRotation } from "oxalis/model/accessors/flycam_accessor"; import { isBrushTool, isTraceTool, @@ -56,7 +42,6 @@ import type { AddAdHocMeshAction, AddPrecomputedMeshAction, } from "oxalis/model/actions/annotation_actions"; -import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; import { updateTemporarySettingAction, updateUserSettingAction, @@ -64,9 +49,9 @@ import { import { setBusyBlockingInfoAction, setToolAction } from "oxalis/model/actions/ui_actions"; import type { ClickSegmentAction, - SetActiveCellAction, CreateCellAction, DeleteSegmentDataAction, + SetActiveCellAction, } from "oxalis/model/actions/volumetracing_actions"; import { finishAnnotationStrokeAction, @@ -74,9 +59,7 @@ import { setSelectedSegmentsOrGroupAction, updateSegmentAction, } from "oxalis/model/actions/volumetracing_actions"; -import BoundingBox from "oxalis/model/bucket_data_handling/bounding_box"; import { markVolumeTransactionEnd } from "oxalis/model/bucket_data_handling/bucket"; -import Dimensions from "oxalis/model/dimensions"; import type { Saga } from "oxalis/model/sagas/effect-generators"; import { select, take } from "oxalis/model/sagas/effect-generators"; import listenToMinCut from "oxalis/model/sagas/min_cut_saga"; @@ -85,34 +68,27 @@ import { requestBucketModificationInVolumeTracing, takeEveryUnlessBusy, } from "oxalis/model/sagas/saga_helpers"; -import { - deleteSegmentDataVolumeAction, - type UpdateAction, - updateSegmentGroups, -} from "oxalis/model/sagas/update_actions"; import { createSegmentVolumeAction, + deleteSegmentDataVolumeAction, deleteSegmentVolumeAction, removeFallbackLayer, + updateMappingName, + updateSegmentGroups, updateSegmentVolumeAction, updateUserBoundingBoxes, updateVolumeTracing, - updateMappingName, + type UpdateAction, } from "oxalis/model/sagas/update_actions"; import type VolumeLayer from "oxalis/model/volumetracing/volumelayer"; import { Model, api } from "oxalis/singletons"; import type { Flycam, SegmentMap, VolumeTracing } from "oxalis/store"; +import type { ActionPattern } from "redux-saga/effects"; import { actionChannel, call, fork, put, takeEvery, takeLatest } from "typed-redux-saga"; -import { - applyLabeledVoxelMapToAllMissingMags, - createVolumeLayer, - labelWithVoxelBuffer2D, - type BooleanBox, -} from "./volume/helpers"; -import maybeInterpolateSegmentationLayer from "./volume/volume_interpolation_saga"; -import messages from "messages"; import { pushSaveQueueTransaction } from "../actions/save_actions"; -import type { ActionPattern } from "redux-saga/effects"; +import { createVolumeLayer, labelWithVoxelBuffer2D, type BooleanBox } from "./volume/helpers"; +import maybeInterpolateSegmentationLayer from "./volume/volume_interpolation_saga"; +import { floodFill } from "./volume/floodfill_saga"; const OVERWRITE_EMPTY_WARNING_KEY = "OVERWRITE-EMPTY-WARNING"; @@ -370,212 +346,6 @@ export function* editVolumeLayerAsync(): Saga { } } -function* getBoundingBoxForFloodFill( - position: Vector3, - currentViewport: OrthoView, -): Saga { - const fillMode = yield* select((state) => state.userConfiguration.fillMode); - const halfBoundingBoxSizeUVW = V3.scale(Constants.FLOOD_FILL_EXTENTS[fillMode], 0.5); - const currentViewportBounding = { - min: V3.sub(position, halfBoundingBoxSizeUVW), - max: V3.add(position, halfBoundingBoxSizeUVW), - }; - - if (fillMode === FillModeEnum._2D) { - // Only use current plane - const thirdDimension = Dimensions.thirdDimensionForPlane(currentViewport); - const numberOfSlices = 1; - currentViewportBounding.min[thirdDimension] = position[thirdDimension]; - currentViewportBounding.max[thirdDimension] = position[thirdDimension] + numberOfSlices; - } - - const datasetBoundingBox = yield* select((state) => getDatasetBoundingBox(state.dataset)); - const { min: clippedMin, max: clippedMax } = new BoundingBox( - currentViewportBounding, - ).intersectedWith(datasetBoundingBox); - return { - min: clippedMin, - max: clippedMax, - }; -} - -const FLOODFILL_PROGRESS_KEY = "FLOODFILL_PROGRESS_KEY"; -export function* floodFill(): Saga { - yield* take("INITIALIZE_VOLUMETRACING"); - const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate); - - while (allowUpdate) { - const floodFillAction = yield* take("FLOOD_FILL"); - - if (floodFillAction.type !== "FLOOD_FILL") { - throw new Error("Unexpected action. Satisfy typescript."); - } - - const { position: positionFloat, planeId } = floodFillAction; - const volumeTracing = yield* select(enforceActiveVolumeTracing); - if (volumeTracing.hasEditableMapping) { - const message = "Volume modification is not allowed when an editable mapping is active."; - Toast.error(message); - console.error(message); - continue; - } - const segmentationLayer = yield* call( - [Model, Model.getSegmentationTracingLayer], - volumeTracing.tracingId, - ); - const { cube } = segmentationLayer; - const seedPosition = Dimensions.roundCoordinate(positionFloat); - const activeCellId = volumeTracing.activeCellId; - const dimensionIndices = Dimensions.getIndices(planeId); - const requestedZoomStep = yield* select((state) => - getActiveMagIndexForLayer(state, segmentationLayer.name), - ); - const magInfo = yield* call(getMagInfo, segmentationLayer.mags); - const labeledZoomStep = magInfo.getClosestExistingIndex(requestedZoomStep); - const additionalCoordinates = yield* select((state) => state.flycam.additionalCoordinates); - const oldSegmentIdAtSeed = cube.getDataValue( - seedPosition, - additionalCoordinates, - null, - labeledZoomStep, - ); - - if (activeCellId === oldSegmentIdAtSeed) { - Toast.warning("The clicked voxel's id is already equal to the active segment id."); - continue; - } - - const busyBlockingInfo = yield* select((state) => state.uiInformation.busyBlockingInfo); - - if (busyBlockingInfo.isBusy) { - console.warn(`Ignoring floodfill request (reason: ${busyBlockingInfo.reason || "unknown"})`); - continue; - } - // As the flood fill will be applied to the volume layer, - // the potentially existing mapping should be locked to ensure a consistent state. - const isModificationAllowed = yield* call( - requestBucketModificationInVolumeTracing, - volumeTracing, - ); - if (!isModificationAllowed) { - continue; - } - yield* put(setBusyBlockingInfoAction(true, "Floodfill is being computed.")); - const boundingBoxForFloodFill = yield* call(getBoundingBoxForFloodFill, seedPosition, planeId); - const progressCallback = createProgressCallback({ - pauseDelay: 200, - successMessageDelay: 2000, - // Since only one floodfill operation can be active at any time, - // a hardcoded key is sufficient. - key: "FLOODFILL_PROGRESS_KEY", - }); - yield* call(progressCallback, false, "Performing floodfill..."); - console.time("cube.floodFill"); - const fillMode = yield* select((state) => state.userConfiguration.fillMode); - - const { - bucketsWithLabeledVoxelsMap: labelMasksByBucketAndW, - wasBoundingBoxExceeded, - coveredBoundingBox, - } = yield* call( - { context: cube, fn: cube.floodFill }, - seedPosition, - additionalCoordinates, - activeCellId, - dimensionIndices, - boundingBoxForFloodFill, - labeledZoomStep, - progressCallback, - fillMode === FillModeEnum._3D, - ); - console.timeEnd("cube.floodFill"); - yield* call(progressCallback, false, "Finalizing floodfill..."); - const indexSet: Set = new Set(); - - for (const labelMaskByIndex of labelMasksByBucketAndW.values()) { - for (const zIndex of labelMaskByIndex.keys()) { - indexSet.add(zIndex); - } - } - - console.time("applyLabeledVoxelMapToAllMissingMags"); - - for (const indexZ of indexSet) { - const labeledVoxelMapFromFloodFill: LabeledVoxelsMap = new Map(); - - for (const [bucketAddress, labelMaskByIndex] of labelMasksByBucketAndW.entries()) { - const map = labelMaskByIndex.get(indexZ); - - if (map != null) { - labeledVoxelMapFromFloodFill.set(bucketAddress, map); - } - } - - applyLabeledVoxelMapToAllMissingMags( - labeledVoxelMapFromFloodFill, - labeledZoomStep, - dimensionIndices, - magInfo, - cube, - activeCellId, - indexZ, - true, - ); - } - - yield* put(finishAnnotationStrokeAction(volumeTracing.tracingId)); - yield* put( - updateSegmentAction( - volumeTracing.activeCellId, - { - somePosition: seedPosition, - someAdditionalCoordinates: additionalCoordinates || undefined, - }, - volumeTracing.tracingId, - ), - ); - - console.timeEnd("applyLabeledVoxelMapToAllMissingMags"); - - if (wasBoundingBoxExceeded) { - yield* call( - progressCallback, - true, - <> - Floodfill is done, but terminated since the labeled volume got too large. A bounding box -
- that represents the labeled volume was added.{Unicode.NonBreakingSpace} - message.destroy(FLOODFILL_PROGRESS_KEY)}> - Close - - , - { - successMessageDelay: 10000, - }, - ); - yield* put( - addUserBoundingBoxAction({ - boundingBox: coveredBoundingBox, - name: `Limits of flood-fill (source_id=${oldSegmentIdAtSeed}, target_id=${activeCellId}, seed=${seedPosition.join( - ",", - )}, timestamp=${new Date().getTime()})`, - color: Utils.getRandomColor(), - isVisible: true, - }), - ); - } else { - yield* call(progressCallback, true, "Floodfill done."); - } - - cube.triggerPushQueue(); - yield* put(setBusyBlockingInfoAction(false)); - - if (floodFillAction.callback != null) { - floodFillAction.callback(); - } - } -} - export function* finishLayer( layer: VolumeLayer, activeTool: AnnotationTool, diff --git a/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts b/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts index 4501f3bce64..68444fa1fdd 100644 --- a/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts +++ b/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts @@ -6,6 +6,7 @@ import { convertCellIdToRGB, getBrushOverlay, getCrossHairOverlay, + getSegmentationAlphaIncrement, getSegmentId, } from "./segmentation.glsl"; import { getMaybeFilteredColorOrFallback } from "./filtering.glsl"; @@ -110,6 +111,7 @@ uniform highp uint LOOKUP_CUCKOO_TWIDTH; uniform float sphericalCapRadius; uniform bool selectiveVisibilityInProofreading; +uniform bool selectiveSegmentVisibility; uniform float viewMode; uniform float alpha; uniform bool renderBucketIndices; @@ -178,6 +180,7 @@ ${compileShader( hasSegmentation ? getBrushOverlay : null, hasSegmentation ? getSegmentId : null, hasSegmentation ? getCrossHairOverlay : null, + hasSegmentation ? getSegmentationAlphaIncrement : null, almostEq, )} @@ -291,25 +294,13 @@ void main() { && hoveredUnmappedSegmentIdHigh == <%= segmentationName %>_unmapped_id_high; bool isActiveCell = activeCellIdLow == <%= segmentationName %>_id_low && activeCellIdHigh == <%= segmentationName %>_id_high; - // Highlight cell only if it's hovered or active during proofreading - // and if segmentation opacity is not zero - float alphaIncrement = isProofreading - ? (isActiveCell - ? (isHoveredUnmappedSegment - ? 0.4 // Highlight the hovered super-voxel of the active segment - : (isHoveredSegment - ? 0.15 // Highlight the not-hovered super-voxels of the hovered segment - : 0.0 - ) - ) - : (isHoveredSegment - ? 0.2 - // We are in proofreading mode, but the current voxel neither belongs - // to the active segment nor is it hovered. When selective visibility - // is enabled, lower the opacity. - : (selectiveVisibilityInProofreading ? -<%= segmentationName %>_alpha : 0.0) - ) - ) : (isHoveredSegment ? 0.2 : 0.0); + float alphaIncrement = getSegmentationAlphaIncrement( + <%= segmentationName %>_alpha, + isHoveredSegment, + isHoveredUnmappedSegment, + isActiveCell + ); + gl_FragColor = vec4(mix( data_color.rgb, convertCellIdToRGB(<%= segmentationName %>_id_high, <%= segmentationName %>_id_low), diff --git a/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts b/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts index 6076008ba5f..f86e4bc6a49 100644 --- a/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts +++ b/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts @@ -351,3 +351,44 @@ export const getSegmentId: ShaderModule = { <% }) %> `, }; + +export const getSegmentationAlphaIncrement: ShaderModule = { + requirements: [], + code: ` + float getSegmentationAlphaIncrement(float alpha, bool isHoveredSegment, bool isHoveredUnmappedSegment, bool isActiveCell) { + // Highlight segment only if + // - it's hovered or + // - active during proofreading + // Also, make segments invisible if selective visibility is turned on (unless the segment + // is active or hovered). + + if (isProofreading) { + if (isActiveCell) { + return (isHoveredUnmappedSegment + ? 0.4 // Highlight the hovered super-voxel of the active segment + : (isHoveredSegment + ? 0.15 // Highlight the not-hovered super-voxels of the hovered segment + : 0.0 + ) + ); + } else { + return (isHoveredSegment + ? 0.2 + // We are in proofreading mode, but the current voxel neither belongs + // to the active segment nor is it hovered. When selective visibility + // is enabled, lower the opacity. + : (selectiveVisibilityInProofreading ? -alpha : 0.0) + ); + } + } + + if (isHoveredSegment) { + return 0.2; + } else if (selectiveSegmentVisibility) { + return isActiveCell ? 0.15 : -alpha; + } else { + return 0.; + } + } + `, +}; diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index 61762134fd8..d056af18b3d 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -330,6 +330,7 @@ export type DatasetConfiguration = { readonly renderMissingDataBlack: boolean; readonly loadingStrategy: LoadingStrategy; readonly segmentationPatternOpacity: number; + readonly selectiveSegmentVisibility: boolean; readonly blendMode: BLEND_MODES; // If nativelyRenderedLayerName is not-null, the layer with // that name (or id) should be rendered without any transforms. @@ -394,6 +395,7 @@ export type UserConfiguration = { // how volume annotations overwrite existing voxels. readonly overwriteMode: OverwriteMode; readonly fillMode: FillMode; + readonly isFloodfillRestrictedToBoundingBox: boolean; readonly interpolationMode: InterpolationMode; readonly useLegacyBindings: boolean; readonly quickSelect: QuickSelectConfig; diff --git a/frontend/javascripts/oxalis/view/action-bar/default-predict-workflow-template.ts b/frontend/javascripts/oxalis/view/action-bar/default-predict-workflow-template.ts index ffaffb19b0c..62973923b0f 100644 --- a/frontend/javascripts/oxalis/view/action-bar/default-predict-workflow-template.ts +++ b/frontend/javascripts/oxalis/view/action-bar/default-predict-workflow-template.ts @@ -8,7 +8,17 @@ export default `predict: model: TO_BE_SET_BY_WORKER config: name: predict - datasource_config: TO_BE_SET_BY_WORKER + datasource_config: + name: TO_BE_SET_BY_WORKER + type: "wkw" + scale: TO_BE_SET_BY_WORKER + data_dir: "/" + path: TO_BE_SET_BY_WORKER + color_name: TO_BE_SET_BY_WORKER + wkw_resolution: TO_BE_SET_BY_WORKER + bounding_box: + topleft: TO_BE_SET_BY_WORKER + size: TO_BE_SET_BY_WORKER # your additional config keys here # your additional tasks here @@ -20,6 +30,7 @@ publish_dataset_meshes: config: name: TO_BE_SET_BY_WORKER public_directory: TO_BE_SET_BY_WORKER + datastore_url: TO_BE_SET_BY_WORKER use_symlinks: False move_dataset_symlink_artifact: True keep_symlinks_to: TO_BE_SET_BY_WORKER`; diff --git a/frontend/javascripts/oxalis/view/action-bar/download_modal_view.tsx b/frontend/javascripts/oxalis/view/action-bar/download_modal_view.tsx index c7091569432..4e0713d595d 100644 --- a/frontend/javascripts/oxalis/view/action-bar/download_modal_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/download_modal_view.tsx @@ -576,6 +576,8 @@ function _DownloadModalView({ ); + const onlyOneMagAvailable = selectedLayerMagInfo.getMagList().length === 1; + const tiffExportTab = ( <> @@ -642,7 +644,7 @@ function _DownloadModalView({ style={{ width: "100%" }} /> {boundingBoxCompatibilityAlerts} - {selectedLayerInfos.additionalAxes != null && ( + {(selectedLayerInfos.additionalAxes?.length || 0) > 0 && ( Mag - - - - - - {mag.join("-")} - - + {!onlyOneMagAvailable && ( + + + + + + {mag.join("-")} + + + )} + {onlyOneMagAvailable &&
{mag.join("-")}
} Estimated file size:{" "} {estimateFileSize(selectedLayer, mag, selectedBoundingBox.boundingBox, exportFormat)}
diff --git a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx index 2693a3d8cc5..000fe1f5ca3 100644 --- a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx @@ -52,7 +52,7 @@ import { setToolAction, showQuickSelectSettingsAction } from "oxalis/model/actio import { updateUserSettingAction } from "oxalis/model/actions/settings_actions"; import { usePrevious, useKeyPress } from "libs/react_hooks"; import { userSettings } from "types/schemas/user_settings.schema"; -import ButtonComponent from "oxalis/view/components/button_component"; +import ButtonComponent, { ToggleButton } from "oxalis/view/components/button_component"; import { MaterializeVolumeAnnotationModal } from "oxalis/view/action-bar/starting_job_modals"; import { ToolsWithOverwriteCapabilities, @@ -91,12 +91,6 @@ const NARROW_BUTTON_STYLE = { paddingLeft: 10, paddingRight: 8, }; -// The z-index is needed so that the blue border of an active button does override the border color of the neighboring non active button. -const ACTIVE_BUTTON_STYLE = { - ...NARROW_BUTTON_STYLE, - borderColor: "var(--ant-color-primary)", - zIndex: 1, -}; const imgStyleForSpaceyIcons = { width: 19, height: 19, @@ -173,7 +167,7 @@ function RadioButtonWithTooltip({ disabledTitle?: string; disabled?: boolean; children: React.ReactNode; - style: React.CSSProperties; + style?: React.CSSProperties; value: string; onClick?: (event: React.MouseEvent) => void; onMouseEnter?: () => void; @@ -219,7 +213,7 @@ function ToolRadioButton({ disabledExplanation?: string; disabled?: boolean; children: React.ReactNode; - style: React.CSSProperties; + style?: React.CSSProperties; value: string; onClick?: (event: React.MouseEvent) => void; onMouseEnter?: () => void; @@ -297,7 +291,6 @@ function OverwriteModeSwitch({ > state.temporaryConfiguration.isMergerModeEnabled, @@ -420,21 +412,22 @@ function AdditionalSkeletonModesButtons() { const toggleMergerMode = () => dispatch(setMergerModeEnabledAction(!isMergerModeEnabled)); - const newNodeNewTreeModeButtonStyle = isNewNodeNewTreeModeOn - ? ACTIVE_BUTTON_STYLE - : NARROW_BUTTON_STYLE; - const mergerModeButtonStyle = isMergerModeEnabled ? ACTIVE_BUTTON_STYLE : NARROW_BUTTON_STYLE; - const isMaterializeVolumeAnnotationEnabled = dataset.dataStore.jobsSupportedByAvailableWorkers.includes( APIJobType.MATERIALIZE_VOLUME_ANNOTATION, ); return ( - - + + Single Node Tree Mode - - + - + {isMergerModeEnabled && isMaterializeVolumeAnnotationEnabled && isUserAdminOrManager && ( setShowMaterializeVolumeAnnotationModal(false)} /> )} - + ); } @@ -940,7 +934,6 @@ export default function ToolbarView() { description="Use left-click to move around and right-click to open a context menu." disabledExplanation="" disabled={false} - style={NARROW_BUTTON_STYLE} value={AnnotationToolEnum.MOVE} > @@ -952,7 +945,6 @@ export default function ToolbarView() { description={skeletonToolDescription} disabledExplanation={disabledInfosForTools[AnnotationToolEnum.SKELETON].explanation} disabled={disabledInfosForTools[AnnotationToolEnum.SKELETON].isDisabled} - style={NARROW_BUTTON_STYLE} value={AnnotationToolEnum.SKELETON} > + + Quick Select Icon + ) : null} - - Quick Select Icon - { dispatch(ensureLayerMappingsAreLoadedAction()); @@ -1154,6 +1141,7 @@ export default function ToolbarView() { className="fas fa-clipboard-check" style={{ opacity: disabledInfosForTools[AnnotationToolEnum.PROOFREAD].isDisabled ? 0.5 : 1, + padding: "0 4px", }} /> @@ -1163,7 +1151,6 @@ export default function ToolbarView() { description="Use to measure distances or areas." disabledExplanation="" disabled={false} - style={NARROW_BUTTON_STYLE} value={AnnotationToolEnum.LINE_MEASUREMENT} > @@ -1194,7 +1181,7 @@ function ToolSpecificSettings({ isControlOrMetaPressed: boolean; isShiftPressed: boolean; }) { - const showCreateTreeButton = hasSkeleton && adaptedActiveTool === AnnotationToolEnum.SKELETON; + const showSkeletonButtons = hasSkeleton && adaptedActiveTool === AnnotationToolEnum.SKELETON; const showNewBoundingBoxButton = adaptedActiveTool === AnnotationToolEnum.BOUNDING_BOX; const showCreateCellButton = hasVolume && VolumeTools.includes(adaptedActiveTool); const showChangeBrushSizeButton = @@ -1207,7 +1194,6 @@ function ToolSpecificSettings({ ); const isAISelectAvailable = features().segmentAnythingEnabled; const isQuickSelectHeuristic = quickSelectConfig.useHeuristic || !isAISelectAvailable; - const heuristicButtonStyle = isQuickSelectHeuristic ? NARROW_BUTTON_STYLE : ACTIVE_BUTTON_STYLE; const quickSelectTooltipText = isAISelectAvailable ? isQuickSelectHeuristic ? "The quick select tool is now working without AI. Activate AI for better results." @@ -1224,16 +1210,7 @@ function ToolSpecificSettings({ return ( <> - {showCreateTreeButton ? ( - - - - - ) : null} + {showSkeletonButtons ? : null} {showNewBoundingBoxButton ? ( - AI - + @@ -1285,7 +1263,7 @@ function ToolSpecificSettings({ ) : null} - {adaptedActiveTool === AnnotationToolEnum.FILL_CELL ? : null} + {adaptedActiveTool === AnnotationToolEnum.FILL_CELL ? : null} {adaptedActiveTool === AnnotationToolEnum.PROOFREAD ? : null} @@ -1350,15 +1328,15 @@ function QuickSelectSettingsPopover() { dispatch(showQuickSelectSettingsAction(open)); }} > - - + @@ -1369,6 +1347,41 @@ const handleSetFillMode = (event: RadioChangeEvent) => { Store.dispatch(updateUserSettingAction("fillMode", event.target.value)); }; +function FloodFillSettings() { + const dispatch = useDispatch(); + const isRestrictedToBoundingBox = useSelector( + (state: OxalisState) => state.userConfiguration.isFloodfillRestrictedToBoundingBox, + ); + const toggleRestrictFloodfillToBoundingBox = () => { + dispatch( + updateUserSettingAction("isFloodfillRestrictedToBoundingBox", !isRestrictedToBoundingBox), + ); + }; + return ( +
+ + + + Restrict floodfill + +
+ ); +} + function FillModeSwitch() { const fillMode = useSelector((state: OxalisState) => state.userConfiguration.fillMode); return ( @@ -1421,30 +1434,26 @@ function ProofReadingComponents() { > - handleToggleAutomaticMeshRendering(!autoRenderMeshes)} > - - + handleToggleSelectiveVisibilityInProofreading(!selectiveVisibilityInProofreading) } > - +
); } diff --git a/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx b/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx index a73f91a2e55..ef0fcf610f0 100644 --- a/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/tracing_actions_view.tsx @@ -496,7 +496,7 @@ class TracingActionsView extends React.PureComponent { hasTracing ? [ {