diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index e4e02843cd7..b7518eecf03 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -13,6 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - 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) +- 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) ### Changed - Renamed "resolution" to "magnification" in more places within the codebase, including local variables. [#8168](https://github.com/scalableminds/webknossos/pull/8168) diff --git a/docs/images/icon_restricted_floodfill.jpg b/docs/images/icon_restricted_floodfill.jpg new file mode 100644 index 00000000000..bb7e1b21599 Binary files /dev/null and b/docs/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/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/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/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/default_state.ts b/frontend/javascripts/oxalis/default_state.ts index da524d26c4f..1349081df4f 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: { 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/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..320cd51678c --- /dev/null +++ b/frontend/javascripts/oxalis/model/sagas/volume/floodfill_saga.tsx @@ -0,0 +1,323 @@ +import { V2, V3 } from "libs/mjs"; +import createProgressCallback from "libs/progress_callback"; +import Toast from "libs/toast"; +import * as Utils from "libs/utils"; +import type { + BoundingBoxType, + LabeledVoxelsMap, + OrthoView, + Vector2, + Vector3, +} 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, + 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 } 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; + +function* getBoundingBoxForFloodFill( + position: Vector3, + currentViewport: OrthoView, +): Saga { + const isRestrictedToBoundingBox = yield* select( + (state) => state.userConfiguration.isFloodfillRestrictedToBoundingBox, + ); + const fillMode = yield* select((state) => state.userConfiguration.fillMode); + if (isRestrictedToBoundingBox) { + const bboxes = yield* select((state) => + getUserBoundingBoxesThatContainPosition(state, position), + ); + if (bboxes.length > 0) { + 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; + } else { + 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 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, + }; +} + +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; + } + const boundingBoxForFloodFill = yield* call(getBoundingBoxForFloodFill, seedPosition, planeId); + if ("failureReason" in boundingBoxForFloodFill) { + Toast.warning(boundingBoxForFloodFill.failureReason, { + key: NO_FLOODFILL_BBOX_TOAST_KEY, + }); + continue; + } 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"); + + let hideSuccessMsgFnBox: { hideFn: () => void } | undefined; + 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 { + hideSuccessMsgFnBox = yield* call(progressCallback, true, "Floodfill done."); + } + } else { + hideSuccessMsgFnBox = yield* call(progressCallback, true, "Floodfill done."); + } + + const floodfillDuration = performance.now() - startTimeOfFloodfill; + const wasFloodfillQuick = floodfillDuration < NO_SUCCESS_MSG_WHEN_WITHIN_MS; + + if (hideSuccessMsgFnBox != null && wasFloodfillQuick) { + hideSuccessMsgFnBox.hideFn(); + } + + cube.triggerPushQueue(); + yield* put(setBusyBlockingInfoAction(false)); + + if (floodFillAction.callback != null) { + floodFillAction.callback(); + } + } +} 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/store.ts b/frontend/javascripts/oxalis/store.ts index 61762134fd8..6b03af7e3f4 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -394,6 +394,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/toolbar_view.tsx b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx index 2693a3d8cc5..a48140cc1b6 100644 --- a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx @@ -1285,7 +1285,7 @@ function ToolSpecificSettings({ ) : null} - {adaptedActiveTool === AnnotationToolEnum.FILL_CELL ? : null} + {adaptedActiveTool === AnnotationToolEnum.FILL_CELL ? : null} {adaptedActiveTool === AnnotationToolEnum.PROOFREAD ? : null} @@ -1369,6 +1369,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 ( 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 ? [ {