diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 2db8a5c7cec..ef2c7e9643b 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -19,6 +19,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Added segmentation layers to the functionality catching the case that more layers are active that the hardware allows. This prevents rendering issue with more than one segmentation layer and multiple color layers. [#6211](https://github.com/scalableminds/webknossos/pull/6211) - Selecting "Download" from the tracing actions now opens a new modal, which lets the user select data for download, start TIFF export jobs or copy code snippets to get started quickly with the webKonossos Python Client. [#6171](https://github.com/scalableminds/webknossos/pull/6171) - Adding a New Volume Layer via the left border tab now gives the option to restrict resolutions for the new layer. [#6229](https://github.com/scalableminds/webknossos/pull/6229) +- Added support for segment interpolation with depths > 2. Also, the feature was changed to work on an explicit trigger (either via the button in the toolbar or via the shortcut V). When triggering the interpolation, the current segment id is interpolated between the current slice and the least-recently annotated slice. [#6235](https://github.com/scalableminds/webknossos/pull/6235) ### Changed - When creating a new annotation with a volume layer (without fallback) for a dataset which has an existing segmentation layer, the original segmentation layer is still listed (and viewable) in the left sidebar. Earlier versions simply hid the original segmentation layer. [#6186](https://github.com/scalableminds/webknossos/pull/6186) @@ -35,5 +36,6 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Fixed a bug which could cause a segmentation layer's "ID mapping" dropdown to disappear. [#6215](https://github.com/scalableminds/webknossos/pull/6215) ### Removed +- Removed the feature to copy a segment from the previous/next slice with the V shortcut. Use the new volume interpolation feature instead (also bound to V and available via the toolbar). [#6235](https://github.com/scalableminds/webknossos/pull/6235) ### Breaking Changes diff --git a/docs/keyboard_shortcuts.md b/docs/keyboard_shortcuts.md index eb0cb8a1f63..21a690fbdd8 100644 --- a/docs/keyboard_shortcuts.md +++ b/docs/keyboard_shortcuts.md @@ -102,8 +102,7 @@ Note that you can enable *Classic Controls* which will behave slightly different | C | Create New Segment | | W | Toggle Modes (Move / Skeleton / Trace / Brush / ...) | | SHIFT + Mousewheel or SHIFT + I, O | Change Brush Size (Brush Mode) | -| V | Copy Segmentation of Current Segment From Previous Slice | -| SHIFT + V | Copy Segmentation of Current Segment From Next Slice | +| V | Interpolate current segment between last labeled and current slice | Note that you can enable *Classic Controls* which won't open a context menu on right-click, but instead erases when the brush/trace tool is activated. @@ -132,4 +131,4 @@ The following binding only works in skeleton/hybrid annotations and if an agglom Note that you can enable *Classic Controls* in the left sidebar. Classic controls are provided for backward compatibility for long-time users and are not recommended for new user accounts. Hence, Classic controls are disabled by default, and webKnossos uses a more intuitive behavior which assigns the most important functionality to the left mouse button (e.g., moving around, selecting/creating/moving nodes). The right mouse button always opens a context-sensitive menu for more complex actions, such as merging two trees. -With classic controls, several mouse controls are modifier-driven and may also use the right-click for actions, such as erasing volume data. \ No newline at end of file +With classic controls, several mouse controls are modifier-driven and may also use the right-click for actions, such as erasing volume data. diff --git a/docs/volume_annotation.md b/docs/volume_annotation.md index 17613a3190d..9014980e7ae 100644 --- a/docs/volume_annotation.md +++ b/docs/volume_annotation.md @@ -73,15 +73,14 @@ Due to performance reasons, 3D flood-fills only work in a small, local bounding Check the `Processing Jobs` page from the `Admin` menu at the top of the screen to track progress or cancel the operation. The finished, processed dataset will appear as new dataset in your dashboard. ### Volume Interpolation -When using the brush or trace tool, you can enable `Volume Interpolation` for faster annotation speed (in a task context, this feature has to be enabled explicitly). -When enabled, it suffices to only label every second slice. The skipped slices will be filled automatically by interpolating between the labeled slices. +When using the brush or trace tool, you can use the `Volume Interpolation` feature for faster annotation speed (in a task context, this feature has to be enabled explicitly). +Simply label a segment in one slice (e.g., z=10), move forward by a few slices (e.g., z=14) and label the segment there. +Now, you can click the "Interpolate" button (or use the shortcut V) to interpolate the segment between the annotated slices (e.g., z=11, z=12, z=13). Note that it is recommended to proof-read the interpolated slices afterwards, since the interpolation is a heuristic. ![Video: Volume Interpolation](https://www.youtube.com/watch?v=-nYv0hA1k4A) -The little arrow at the interpolation button in the toolbar indicates whether you are currently labeling with increasing or decreasing X/Y/Z. - ### Mappings / On-Demand Agglomeration With webKnossos it is possible to apply a precomputed agglomeration file to re-map/combine over-segmented volume annotations on-demand. Instead of having to materialize one or more agglomeration results as separate segmentation layers, ID mappings allow researchers to apply and compare different agglomeration strategies of their data for experimentation. diff --git a/frontend/javascripts/admin/tasktype/recommended_configuration_view.tsx b/frontend/javascripts/admin/tasktype/recommended_configuration_view.tsx index 3b551c9a659..d691ebc8508 100644 --- a/frontend/javascripts/admin/tasktype/recommended_configuration_view.tsx +++ b/frontend/javascripts/admin/tasktype/recommended_configuration_view.tsx @@ -49,7 +49,6 @@ function getRecommendedConfigByCategory() { }, volume: { brushSize: 50, - isVolumeInterpolationEnabled: false, }, }; } diff --git a/frontend/javascripts/libs/mjs.ts b/frontend/javascripts/libs/mjs.ts index df4bf34c397..1403ef445bd 100644 --- a/frontend/javascripts/libs/mjs.ts +++ b/frontend/javascripts/libs/mjs.ts @@ -282,6 +282,15 @@ const V3 = { toArray(vec: ArrayLike): Vector3 { return [vec[0], vec[1], vec[2]]; }, + + roundElementToResolution(vec: Vector3, resolution: Vector3, index: 0 | 1 | 2): Vector3 { + // Rounds the element at the position referenced by index so that it's divisible by the + // resolution element. + // For example: roundElementToResolution([11, 12, 13], [4, 4, 2], 2) == [11, 12, 12] + const res: Vector3 = [vec[0], vec[1], vec[2]]; + res[index] = Math.floor(res[index] / resolution[index]) * resolution[index]; + return res; + }, }; export { M4x4, V2, V3 }; diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 135060e3825..c8c39f88d53 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -915,3 +915,13 @@ export function coalesce(obj: { [key: string]: T }, field: T): T | null { } return null; } + +export function pluralize(str: string, count: number, optPluralForm: string | null = null): string { + if (count < 2) { + return str; + } + if (optPluralForm != null) { + return optPluralForm; + } + return `${str}s`; +} diff --git a/frontend/javascripts/messages.ts b/frontend/javascripts/messages.ts index 13e32ff44cd..2a9575c0780 100644 --- a/frontend/javascripts/messages.ts +++ b/frontend/javascripts/messages.ts @@ -43,7 +43,6 @@ export const settings: Partial> = gpuMemoryFactor: "Hardware Utilization", overwriteMode: "Volume Annotation Overwrite Mode", useLegacyBindings: "Classic Controls", - isVolumeInterpolationEnabled: "Volume Interpolation", }; export const settingsTooltips: Partial> = { loadingStrategy: `You can choose between loading the best quality first diff --git a/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.tsx b/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.tsx index baa6124a534..cc49b77fb92 100644 --- a/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.tsx +++ b/frontend/javascripts/oxalis/controller/viewmodes/plane_controller.tsx @@ -30,7 +30,7 @@ import Toast from "libs/toast"; import * as Utils from "libs/utils"; import { createCellAction, - copySegmentationLayerAction, + interpolateSegmentationLayerAction, } from "oxalis/model/actions/volumetracing_actions"; import { cycleToolAction } from "oxalis/model/actions/ui_actions"; import { @@ -130,10 +130,7 @@ class VolumeKeybindings { return { c: () => Store.dispatch(createCellAction()), v: () => { - Store.dispatch(copySegmentationLayerAction()); - }, - "shift + v": () => { - Store.dispatch(copySegmentationLayerAction(true)); + Store.dispatch(interpolateSegmentationLayerAction()); }, }; } diff --git a/frontend/javascripts/oxalis/default_state.ts b/frontend/javascripts/oxalis/default_state.ts index a91d9a4b16b..3a02c554602 100644 --- a/frontend/javascripts/oxalis/default_state.ts +++ b/frontend/javascripts/oxalis/default_state.ts @@ -76,8 +76,6 @@ const defaultState: OxalisState = { overwriteMode: OverwriteModeEnum.OVERWRITE_ALL, fillMode: FillModeEnum._2D, useLegacyBindings: false, - isVolumeInterpolationEnabled: false, - volumeInterpolationDepth: 2, }, temporaryConfiguration: { viewMode: Constants.MODE_PLANE_TRACING, diff --git a/frontend/javascripts/oxalis/model/accessors/volumetracing_accessor.ts b/frontend/javascripts/oxalis/model/accessors/volumetracing_accessor.ts index 8183fcad2cb..d5524778912 100644 --- a/frontend/javascripts/oxalis/model/accessors/volumetracing_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/volumetracing_accessor.ts @@ -11,6 +11,7 @@ import type { import type { ActiveMappingInfo, HybridTracing, + LabelAction, OxalisState, SegmentMap, Tracing, @@ -27,12 +28,14 @@ import { getVisibleSegmentationLayer, } from "oxalis/model/accessors/dataset_accessor"; import { getMaxZoomStepDiff } from "oxalis/model/bucket_data_handling/loading_strategy_logic"; -import { getRequestLogZoomStep } from "oxalis/model/accessors/flycam_accessor"; +import { getFlooredPosition, getRequestLogZoomStep } from "oxalis/model/accessors/flycam_accessor"; import { reuseInstanceOnEquality } from "oxalis/model/accessors/accessor_helpers"; +import { V3 } from "libs/mjs"; export function getVolumeTracings(tracing: Tracing): Array { return tracing.volumes; } + export function getVolumeTracingById(tracing: Tracing, tracingId: string): VolumeTracing { const volumeTracing = tracing.volumes.find((t) => t.tracingId === tracingId); @@ -42,10 +45,12 @@ export function getVolumeTracingById(tracing: Tracing, tracingId: string): Volum return volumeTracing; } + export function getVolumeTracingLayers(dataset: APIDataset): Array { const layers = getSegmentationLayers(dataset); return layers.filter((layer) => layer.tracingId != null); } + export function getVolumeTracingByLayerName( tracing: Tracing, layerName: string, @@ -55,14 +60,17 @@ export function getVolumeTracingByLayerName( const volumeTracing = tracing.volumes.find((t) => t.tracingId === layerName); return volumeTracing; } + export function hasVolumeTracings(tracing: Tracing): boolean { return tracing.volumes.length > 0; } + export function getVolumeDescriptors( annotation: APIAnnotation | APIAnnotationCompact | HybridTracing, ): Array { return annotation.annotationLayers.filter((layer) => layer.typ === "Volume"); } + export function getVolumeDescriptorById( annotation: APIAnnotation | APIAnnotationCompact | HybridTracing, tracingId: string, @@ -77,6 +85,7 @@ export function getVolumeDescriptorById( return descriptors[0]; } + export function getReadableNameByVolumeTracingId( tracing: APIAnnotation | APIAnnotationCompact | HybridTracing, tracingId: string, @@ -115,10 +124,12 @@ export function getServerVolumeTracings( ); return volumeTracings; } + export function getActiveCellId(volumeTracing: VolumeTracing): number { const { activeCellId } = volumeTracing; return activeCellId; } + export function getContourTracingMode(volumeTracing: VolumeTracing): ContourMode { const { contourTracingMode } = volumeTracing; return contourTracingMode; @@ -137,6 +148,7 @@ const MAG_THRESHOLDS_FOR_ZOOM: Partial> = { export function isVolumeTool(tool: AnnotationTool): boolean { return VolumeTools.indexOf(tool) > -1; } + export function isVolumeAnnotationDisallowedForZoom(tool: AnnotationTool, state: OxalisState) { if (state.tracing.volumes.length === 0) { return true; @@ -171,12 +183,14 @@ export function getMaximumBrushSize(state: OxalisState) { // we double the maximum brush size. return MAX_BRUSH_SIZE_FOR_MAG1 * 2 ** lowestExistingResolutionIndex; } + export function isSegmentationMissingForZoomstep( state: OxalisState, maxZoomStepForSegmentation: number, ): boolean { return getRequestLogZoomStep(state) > maxZoomStepForSegmentation; } + export function getRequestedOrVisibleSegmentationLayer( state: OxalisState, layerName: string | null | undefined, @@ -230,9 +244,11 @@ export function getRequestedOrDefaultSegmentationTracingLayer( return getTracingForSegmentationLayer(state, visibleLayer); } + export function getActiveSegmentationTracing(state: OxalisState): VolumeTracing | null | undefined { return getRequestedOrDefaultSegmentationTracingLayer(state, null); } + export function getActiveSegmentationTracingLayer( state: OxalisState, ): APISegmentationLayer | null | undefined { @@ -244,6 +260,7 @@ export function getActiveSegmentationTracingLayer( return getSegmentationLayerForTracing(state, tracing); } + export function enforceActiveVolumeTracing(state: OxalisState): VolumeTracing { const tracing = getActiveSegmentationTracing(state); @@ -253,6 +270,7 @@ export function enforceActiveVolumeTracing(state: OxalisState): VolumeTracing { return tracing; } + export function getRequestedOrVisibleSegmentationLayerEnforced( state: OxalisState, layerName: string | null | undefined, @@ -268,6 +286,7 @@ export function getRequestedOrVisibleSegmentationLayerEnforced( "No segmentation layer is currently visible. Pass a valid layerName (you may want to use `getSegmentationLayerName`)", ); } + export function getNameOfRequestedOrVisibleSegmentationLayer( state: OxalisState, layerName: string | null | undefined, @@ -275,6 +294,7 @@ export function getNameOfRequestedOrVisibleSegmentationLayer( const layer = getRequestedOrVisibleSegmentationLayer(state, layerName); return layer != null ? layer.name : null; } + export function getSegmentsForLayer( state: OxalisState, layerName: string | null | undefined, @@ -291,6 +311,7 @@ export function getSegmentsForLayer( return state.localSegmentationData[layer.name].segments; } + export function getVisibleSegments(state: OxalisState): SegmentMap | null | undefined { const layer = getVisibleSegmentationLayer(state); @@ -387,9 +408,31 @@ function _getRenderableResolutionForActiveSegmentationTracing(state: OxalisState export const getRenderableResolutionForActiveSegmentationTracing = reuseInstanceOnEquality( _getRenderableResolutionForActiveSegmentationTracing, ); + export function getMappingInfoForVolumeTracing( state: OxalisState, tracingId: string | null | undefined, ): ActiveMappingInfo { return getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId); } + +export function getLastLabelAction(volumeTracing: VolumeTracing): LabelAction | undefined { + return volumeTracing.lastLabelActions[0]; +} + +export function getLabelActionFromPreviousSlice( + state: OxalisState, + volumeTracing: VolumeTracing, + resolution: Vector3, + dim: 0 | 1 | 2, +): LabelAction | undefined { + // Gets the last label action which was performed on a different slice. + // Note that in coarser mags (e.g., 8-8-2), the comparison of the coordinates + // is done while respecting how the coordinates are clipped due to that resolution. + const adapt = (vec: Vector3) => V3.roundElementToResolution(vec, resolution, dim); + const position = adapt(getFlooredPosition(state.flycam)); + + return volumeTracing.lastLabelActions.find( + (el) => Math.floor(adapt(el.centroid)[dim]) != position[dim], + ); +} diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 9195e9b9120..da0ed08b7f2 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -49,9 +49,8 @@ export type ClickSegmentAction = { cellId: number; somePosition: Vector3; }; -export type CopySegmentationLayerAction = { - type: "COPY_SEGMENTATION_LAYER"; - source: "previousLayer" | "nextLayer"; +export type InterpolateSegmentationLayerAction = { + type: "INTERPOLATE_SEGMENTATION_LAYER"; }; export type MaybeUnmergedBucketLoadedPromise = null | Promise; export type AddBucketToUndoAction = { @@ -118,7 +117,7 @@ export type VolumeTracingAction = | FinishAnnotationStrokeAction | SetMousePositionAction | HideBrushAction - | CopySegmentationLayerAction + | InterpolateSegmentationLayerAction | SetContourTracingModeAction | SetSegmentsAction | UpdateSegmentAction @@ -203,9 +202,8 @@ export const updateSegmentAction = ( layerName, timestamp, }); -export const copySegmentationLayerAction = (fromNext?: boolean): CopySegmentationLayerAction => ({ - type: "COPY_SEGMENTATION_LAYER", - source: fromNext ? "nextLayer" : "previousLayer", +export const interpolateSegmentationLayerAction = (): InterpolateSegmentationLayerAction => ({ + type: "INTERPOLATE_SEGMENTATION_LAYER", }); export const updateDirectionAction = (centroid: Vector3): UpdateDirectionAction => ({ type: "UPDATE_DIRECTION", diff --git a/frontend/javascripts/oxalis/model/dimensions.ts b/frontend/javascripts/oxalis/model/dimensions.ts index e4033b69da4..da7b2aa9fcb 100644 --- a/frontend/javascripts/oxalis/model/dimensions.ts +++ b/frontend/javascripts/oxalis/model/dimensions.ts @@ -1,5 +1,5 @@ -import type { OrthoView, Vector3 } from "oxalis/constants"; -import { OrthoViews } from "oxalis/constants"; +import { OrthoView, OrthoViews, Vector3 } from "oxalis/constants"; + export type DimensionIndices = 0 | 1 | 2; export type DimensionMap = [DimensionIndices, DimensionIndices, DimensionIndices]; // This is a class with static methods dealing with dimensions and @@ -69,6 +69,22 @@ const Dimensions = { } }, + dimensionNameForIndex(dim: DimensionIndices): string { + switch (dim) { + case 2: + return "Z"; + + case 0: + return "X"; + + case 1: + return "Y"; + + default: + throw new Error(`Unrecognized dimension: ${dim}`); + } + }, + roundCoordinate(coordinate: Vector3): Vector3 { return [Math.floor(coordinate[0]), Math.floor(coordinate[1]), Math.floor(coordinate[2])]; }, diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts index b3276f806ce..bd87574a40a 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts @@ -123,12 +123,13 @@ function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { // If oldSegment exists, its creationTime will be // used by ...oldSegment creationTime: action.timestamp, + name: null, ...oldSegment, ...segment, somePosition, id: segmentId, }; - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type '{ somePosition: Vector3; id: num... Remove this comment to see the full error message + const newSegmentMap = segments.set(segmentId, newSegment); if (updateInfo.type === "UPDATE_VOLUME_TRACING") { @@ -150,7 +151,7 @@ export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing): const userBoundingBoxes = convertUserBoundingBoxesFromServerToFrontend(tracing.userBoundingBoxes); const volumeTracing = { createdTimestamp: tracing.createdTimestamp, - type: "volume", + type: "volume" as "volume", segments: new DiffableMap( tracing.segments.map((segment) => [ segment.segmentId, @@ -162,7 +163,7 @@ export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing): ]), ), activeCellId: 0, - lastCentroid: null, + lastLabelActions: [], contourTracingMode: ContourModeEnum.DRAW, contourList: [], maxCellId, @@ -172,7 +173,6 @@ export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing): fallbackLayer: tracing.fallbackLayer, userBoundingBoxes, }; - // @ts-expect-error ts-migrate(2322) FIXME: Type '{ createdTimestamp: number; type: string; se... Remove this comment to see the full error message return volumeTracing; } diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts index cf42e0d319d..2f9354c5029 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer_helpers.ts @@ -1,6 +1,6 @@ import update from "immutability-helper"; -import type { ContourMode, Vector3 } from "oxalis/constants"; -import type { OxalisState, VolumeTracing } from "oxalis/store"; +import { ContourMode, OrthoViews, OrthoViewWithoutTD, Vector3 } from "oxalis/constants"; +import type { LabelAction, OxalisState, VolumeTracing } from "oxalis/store"; import { isVolumeAnnotationDisallowedForZoom } from "oxalis/model/accessors/volumetracing_accessor"; import { setDirectionReducer } from "oxalis/model/reducers/flycam_reducer"; import { updateKey } from "oxalis/model/helpers/deep_update"; @@ -43,6 +43,8 @@ export function createCellReducer(state: OxalisState, volumeTracing: VolumeTraci activeCellId: id, }); } + +const MAXIMUM_LABEL_ACTIONS_COUNT = 50; export function updateDirectionReducer( state: OxalisState, volumeTracing: VolumeTracing, @@ -50,16 +52,26 @@ export function updateDirectionReducer( ) { let newState = state; - if (volumeTracing.lastCentroid != null) { + const lastCentroid = volumeTracing.lastLabelActions[0]?.centroid; + if (lastCentroid != null) { newState = setDirectionReducer(state, [ - centroid[0] - volumeTracing.lastCentroid[0], - centroid[1] - volumeTracing.lastCentroid[1], - centroid[2] - volumeTracing.lastCentroid[2], + centroid[0] - lastCentroid[0], + centroid[1] - lastCentroid[1], + centroid[2] - lastCentroid[2], ]); } + const plane: OrthoViewWithoutTD = + state.viewModeData.plane.activeViewport != OrthoViews.TDView + ? state.viewModeData.plane.activeViewport + : OrthoViews.PLANE_XY; + + const labelAction: LabelAction = { centroid, plane }; + return updateVolumeTracing(newState, volumeTracing.tracingId, { - lastCentroid: centroid, + lastLabelActions: [labelAction] + .concat(volumeTracing.lastLabelActions) + .slice(0, MAXIMUM_LABEL_ACTIONS_COUNT), }); } export function addToLayerReducer( diff --git a/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts b/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts index f1546cc9864..d7ab7f478e3 100644 --- a/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts +++ b/frontend/javascripts/oxalis/model/sagas/volume/helpers.ts @@ -160,6 +160,7 @@ export function* labelWithVoxelBuffer2D( contourTracingMode: ContourMode, overwriteMode: OverwriteMode, labeledZoomStep: number, + viewport: OrthoView, ): Saga { const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate); if (!allowUpdate) return; @@ -172,8 +173,7 @@ export function* labelWithVoxelBuffer2D( ); const { cube } = segmentationLayer; const currentLabeledVoxelMap: LabeledVoxelsMap = new Map(); - const activeViewport = yield* select((state) => state.viewModeData.plane.activeViewport); - const dimensionIndices = Dimensions.getIndices(activeViewport); + const dimensionIndices = Dimensions.getIndices(viewport); const resolutionInfo = yield* call(getResolutionInfo, segmentationLayer.resolutions); const labeledResolution = resolutionInfo.getResolutionByIndexOrThrow(labeledZoomStep); diff --git a/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts b/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts index 032fe3d56d9..6ec55eb9717 100644 --- a/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts @@ -2,11 +2,12 @@ import cwise from "cwise"; import distanceTransform from "distance-transform"; import { V2, V3 } from "libs/mjs"; import Toast from "libs/toast"; -import ndarray from "ndarray"; +import { pluralize } from "libs/utils"; +import ndarray, { NdArray } from "ndarray"; import api from "oxalis/api/internal_api"; import { - AnnotationTool, ContourModeEnum, + OrthoViews, ToolsWithInterpolationCapabilities, Vector3, } from "oxalis/constants"; @@ -15,16 +16,106 @@ import { getResolutionInfo } from "oxalis/model/accessors/dataset_accessor"; import { getFlooredPosition, getRequestLogZoomStep } from "oxalis/model/accessors/flycam_accessor"; import { enforceActiveVolumeTracing, + getActiveSegmentationTracing, getActiveSegmentationTracingLayer, + getLabelActionFromPreviousSlice, + getLastLabelAction, isVolumeAnnotationDisallowedForZoom, } from "oxalis/model/accessors/volumetracing_accessor"; import Dimensions from "oxalis/model/dimensions"; import type { Saga } from "oxalis/model/sagas/effect-generators"; import { select } from "oxalis/model/sagas/effect-generators"; -import VolumeLayer, { VoxelBuffer2D } from "oxalis/model/volumetracing/volumelayer"; +import { VoxelBuffer2D } from "oxalis/model/volumetracing/volumelayer"; +import { OxalisState } from "oxalis/store"; import { call } from "typed-redux-saga"; import { createVolumeLayer, getBoundingBoxForViewport, labelWithVoxelBuffer2D } from "./helpers"; +export const MAXIMUM_INTERPOLATION_DEPTH = 8; + +export function getInterpolationInfo(state: OxalisState, explanationPrefix: string) { + const isAllowed = state.tracing.restrictions.volumeInterpolationAllowed; + const volumeTracing = getActiveSegmentationTracing(state); + let interpolationDepth = 0; + if (!volumeTracing) { + // Return dummy values, since the feature should be disabled, anyway + return { + tooltipTitle: "Volume Interpolation", + disabledExplanation: "Only available when a volume annotation exists.", + isDisabled: true, + activeViewport: OrthoViews.PLANE_XY, + previousCentroid: null, + labeledResolution: [1, 1, 1] as Vector3, + labeledZoomStep: 0, + interpolationDepth, + }; + } + const mostRecentLabelAction = getLastLabelAction(volumeTracing); + + const activeViewport = mostRecentLabelAction?.plane || OrthoViews.PLANE_XY; + const thirdDim = Dimensions.thirdDimensionForPlane(activeViewport); + + const requestedZoomStep = getRequestLogZoomStep(state); + const segmentationLayer = Model.getSegmentationTracingLayer(volumeTracing.tracingId); + const resolutionInfo = getResolutionInfo(segmentationLayer.resolutions); + const labeledZoomStep = resolutionInfo.getClosestExistingIndex(requestedZoomStep); + const labeledResolution = resolutionInfo.getResolutionByIndexOrThrow(labeledZoomStep); + + const previousCentroid = getLabelActionFromPreviousSlice( + state, + volumeTracing, + labeledResolution, + thirdDim, + )?.centroid; + + let disabledExplanation = null; + let tooltipAddendum = ""; + + if (previousCentroid != null) { + const position = getFlooredPosition(state.flycam); + // Note that in coarser mags (e.g., 8-8-2), the comparison of the coordinates + // is done while respecting how the coordinates are clipped due to that resolution. + // For example, in mag 8-8-2, the z distance needs to be divided by two, since it is measured + // in global coordinates. + const adapt = (vec: Vector3) => V3.roundElementToResolution(vec, labeledResolution, thirdDim); + interpolationDepth = Math.floor( + Math.abs( + V3.sub(adapt(previousCentroid), adapt(position))[thirdDim] / labeledResolution[thirdDim], + ), + ); + + if (interpolationDepth > MAXIMUM_INTERPOLATION_DEPTH) { + disabledExplanation = `${explanationPrefix} last labeled slice is too many slices away (distance > ${MAXIMUM_INTERPOLATION_DEPTH}).`; + } else if (interpolationDepth < 2) { + disabledExplanation = `${explanationPrefix} last labeled slice should be at least 2 slices away.`; + } else { + tooltipAddendum = `Labels ${interpolationDepth - 1} ${pluralize( + "slice", + interpolationDepth - 1, + )} along ${Dimensions.dimensionNameForIndex(thirdDim)}`; + } + } else { + disabledExplanation = `${explanationPrefix} all recent label actions were performed on the current slice.`; + } + + const isPossible = disabledExplanation == null; + tooltipAddendum = disabledExplanation || tooltipAddendum; + + const tooltipTitle = isAllowed + ? `Interpolate current segment between last labeled and current slice (V) – ${tooltipAddendum}` + : "Volume Interpolation was disabled for this annotation."; + const isDisabled = !(isAllowed && isPossible); + return { + tooltipTitle, + disabledExplanation, + isDisabled, + activeViewport, + previousCentroid, + labeledResolution, + labeledZoomStep, + interpolationDepth, + }; +} + const isEqual = cwise({ args: ["array", "scalar"], body: function body(a: number, b: number) { @@ -32,6 +123,27 @@ const isEqual = cwise({ }, }); +const isNonZero = cwise({ + args: ["array"], + // The following function is parsed by cwise which is why + // the shorthand syntax is not supported. + // Also, cwise uses this function content to build + // the target function. Adding a return here would not + // yield the desired behavior for isNonZero. + // eslint-disable-next-line consistent-return, object-shorthand + body: function (a) { + if (a > 0) { + return true; + } + }, + // The following function is parsed by cwise which is why + // the shorthand syntax is not supported. + // eslint-disable-next-line object-shorthand + post: function () { + return false; + }, +}) as (arr: NdArray) => boolean; + const mul = cwise({ args: ["array", "scalar"], body: function body(a: number, b: number) { @@ -98,29 +210,23 @@ function signedDist(arr: ndarray.NdArray) { return arr; } -export default function* maybeInterpolateSegmentationLayer( - layer: VolumeLayer, - isDrawing: boolean, - activeTool: AnnotationTool, -): Saga { +export default function* maybeInterpolateSegmentationLayer(): Saga { const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate); if (!allowUpdate) return; - if (!ToolsWithInterpolationCapabilities.includes(activeTool) || !isDrawing) { + const activeTool = yield* select((state) => state.uiInformation.activeTool); + if (!ToolsWithInterpolationCapabilities.includes(activeTool)) { return; } - const isVolumeInterpolationEnabled = yield* select( - (state) => - state.userConfiguration.isVolumeInterpolationEnabled && - state.tracing.restrictions.volumeInterpolationAllowed, + const isVolumeInterpolationAllowed = yield* select( + (state) => state.tracing.restrictions.volumeInterpolationAllowed, ); - if (!isVolumeInterpolationEnabled) { + if (!isVolumeInterpolationAllowed) { return; } - const activeViewport = yield* select((state) => state.viewModeData.plane.activeViewport); const overwriteMode = yield* select((state) => state.userConfiguration.overwriteMode); // Disable copy-segmentation for the same zoom steps where the brush/trace tool is forbidden, too. @@ -135,19 +241,23 @@ export default function* maybeInterpolateSegmentationLayer( return; } - const volumeTracing = yield* select(enforceActiveVolumeTracing); - const segmentationLayer = yield* call( - [Model, Model.getSegmentationTracingLayer], - volumeTracing.tracingId, + const { + activeViewport, + previousCentroid, + disabledExplanation, + labeledResolution, + labeledZoomStep, + interpolationDepth, + } = yield* select((state) => + getInterpolationInfo(state, "Could not interpolate segment because"), ); - const requestedZoomStep = yield* select((state) => getRequestLogZoomStep(state)); - const resolutionInfo = yield* call(getResolutionInfo, segmentationLayer.resolutions); - const labeledZoomStep = resolutionInfo.getClosestExistingIndex(requestedZoomStep); + + const volumeTracing = yield* select(enforceActiveVolumeTracing); + const [firstDim, secondDim, thirdDim] = Dimensions.getIndices(activeViewport); const position = yield* select((state) => getFlooredPosition(state.flycam)); const activeCellId = volumeTracing.activeCellId; - const labeledResolution = resolutionInfo.getResolutionByIndexOrThrow(labeledZoomStep); const spaceDirectionOrtho = yield* select((state) => state.flycam.spaceDirectionOrtho); const directionFactor = spaceDirectionOrtho[thirdDim]; @@ -156,26 +266,21 @@ export default function* maybeInterpolateSegmentationLayer( return; } - // Annotate only every n-th slice while the remaining ones are interpolated automatically. - const interpolationDepth = yield* select( - (store) => store.userConfiguration.volumeInterpolationDepth, - ); - - const drawnBoundingBoxMag1 = layer.getLabeledBoundingBox(); - if (drawnBoundingBoxMag1 == null) { + if (disabledExplanation != null || previousCentroid == null) { + // A disabledExplanation should always exist if previousCentroid is null, + // but this logic is not inferred by TS. + if (disabledExplanation) { + Toast.warning(disabledExplanation); + } return; } + const viewportBoxMag1 = yield* call(getBoundingBoxForViewport, position, activeViewport); + const transpose = (vector: Vector3) => Dimensions.transDim(vector, activeViewport); - const uvSize = V3.scale3(drawnBoundingBoxMag1.getSize(), transpose([1, 1, 0])); - const viewportBoxMag1 = yield* call(getBoundingBoxForViewport, position, activeViewport); - const relevantBoxMag1 = drawnBoundingBoxMag1 - // Increase the drawn region by a factor of 2 (use half the size as a padding on each size) - .paddedWithMargins(V3.scale(uvSize, 0.5)) - // Intersect with the viewport - .intersectedWith(viewportBoxMag1) - // Also consider the n previous/next slices + const relevantBoxMag1 = viewportBoxMag1 + // Consider the n previous/next slices .paddedWithSignedMargins( transpose([0, 0, -directionFactor * interpolationDepth * labeledResolution[thirdDim]]), ) @@ -220,6 +325,13 @@ export default function* maybeInterpolateSegmentationLayer( isEqual(firstSlice, activeCellId); isEqual(lastSlice, activeCellId); + if (!isNonZero(firstSlice) || !isNonZero(lastSlice)) { + Toast.warning( + `Could not interpolate segment, because id ${activeCellId} was not found in source/target slice.`, + ); + return; + } + const firstSliceDists = signedDist(firstSlice); const lastSliceDists = signedDist(lastSlice); @@ -245,6 +357,7 @@ export default function* maybeInterpolateSegmentationLayer( ContourModeEnum.DRAW, overwriteMode, labeledZoomStep, + activeViewport, ); } } diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx index 048d2fb80d2..c6cd5f57caf 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx @@ -26,7 +26,6 @@ import { CONTOUR_COLOR_DELETE, CONTOUR_COLOR_NORMAL } from "oxalis/geometries/co import Model from "oxalis/model"; import { getBoundaries, getResolutionInfo } from "oxalis/model/accessors/dataset_accessor"; import { - getFlooredPosition, getPosition, getRequestLogZoomStep, getRotation, @@ -69,13 +68,11 @@ import { } 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 DataLayer from "oxalis/model/data_layer"; 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"; import { takeEveryUnlessBusy } from "oxalis/model/sagas/saga_helpers"; -import { getHalfViewportExtents } from "oxalis/model/sagas/saga_selectors"; import type { UpdateAction } from "oxalis/model/sagas/update_actions"; import { createSegmentVolumeAction, @@ -85,8 +82,7 @@ import { updateUserBoundingBoxes, updateVolumeTracing, } from "oxalis/model/sagas/update_actions"; -import VolumeLayer, { getFast3DCoordinateHelper } from "oxalis/model/volumetracing/volumelayer"; -import { applyVoxelMap } from "oxalis/model/volumetracing/volume_annotation_sampling"; +import VolumeLayer from "oxalis/model/volumetracing/volumelayer"; import type { Flycam, SegmentMap, VolumeTracing } from "oxalis/store"; import { getBBoxNameForPartialFloodfill } from "oxalis/view/right-border-tabs/bounding_box_tab"; import React from "react"; @@ -101,9 +97,9 @@ import maybeInterpolateSegmentationLayer from "./volume/volume_interpolation_sag export function* watchVolumeTracingAsync(): Saga { yield* take("WK_READY"); yield* takeEveryUnlessBusy( - "COPY_SEGMENTATION_LAYER", - copySegmentationLayer, - "Copying from neighbor slice", + "INTERPOLATE_SEGMENTATION_LAYER", + maybeInterpolateSegmentationLayer, + "Interpolating segment", ); yield* fork(warnOfTooLowOpacity); } @@ -205,6 +201,7 @@ export function* editVolumeLayerAsync(): Saga { contourTracingMode, overwriteMode, labeledZoomStep, + initialViewport, ); } @@ -249,6 +246,7 @@ export function* editVolumeLayerAsync(): Saga { contourTracingMode, overwriteMode, labeledZoomStep, + activeViewport, ); } @@ -258,6 +256,7 @@ export function* editVolumeLayerAsync(): Saga { contourTracingMode, overwriteMode, labeledZoomStep, + activeViewport, ); } @@ -271,6 +270,7 @@ export function* editVolumeLayerAsync(): Saga { contourTracingMode, overwriteMode, labeledZoomStep, + initialViewport, ); // Update the position of the current segment to the last position of the most recent annotation stroke. yield* put( @@ -283,8 +283,6 @@ export function* editVolumeLayerAsync(): Saga { ), ); - yield* call(maybeInterpolateSegmentationLayer, currentLayer, isDrawing, activeTool); - yield* put(finishAnnotationStrokeAction(volumeTracing.tracingId)); } } @@ -323,142 +321,6 @@ function* getBoundingBoxForFloodFill( }; } -function* copySegmentationLayer(action: Action): Saga { - if (action.type !== "COPY_SEGMENTATION_LAYER") { - throw new Error("Satisfy typescript"); - } - - const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate); - if (!allowUpdate) return; - const activeViewport = yield* select((state) => state.viewModeData.plane.activeViewport); - - if (activeViewport === "TDView") { - // Cannot copy labels from 3D view - return; - } - - // Disable copy-segmentation for the same zoom steps where the trace tool is forbidden, too, - // to avoid large performance lags. - const isResolutionTooLow = yield* select((state) => - isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.TRACE, state), - ); - - if (isResolutionTooLow) { - Toast.warning( - 'The "copy segmentation"-feature is not supported at this zoom level. Please zoom in further.', - ); - return; - } - - const volumeTracing = yield* select(enforceActiveVolumeTracing); - const segmentationLayer: DataLayer = yield* call( - [Model, Model.getSegmentationTracingLayer], - volumeTracing.tracingId, - ); - const { cube } = segmentationLayer; - const requestedZoomStep = yield* select((state) => getRequestLogZoomStep(state)); - const resolutionInfo = yield* call(getResolutionInfo, segmentationLayer.resolutions); - const labeledZoomStep = resolutionInfo.getClosestExistingIndex(requestedZoomStep); - const dimensionIndices = Dimensions.getIndices(activeViewport); - const position = yield* select((state) => getFlooredPosition(state.flycam)); - const [halfViewportExtentX, halfViewportExtentY] = yield* call( - getHalfViewportExtents, - activeViewport, - ); - const activeCellId = volumeTracing.activeCellId; - const labeledVoxelMapOfCopiedVoxel: LabeledVoxelsMap = new Map(); - - function copyVoxelLabel(voxelTemplateAddress: Vector3, voxelTargetAddress: Vector3) { - const templateLabelValue = cube.getDataValue(voxelTemplateAddress, null, labeledZoomStep); - - // Only copy voxels from the previous layer which belong to the current cell - if (templateLabelValue === activeCellId) { - const currentLabelValue = cube.getDataValue(voxelTargetAddress, null, labeledZoomStep); - - // Do not overwrite already labeled voxels - if (currentLabelValue === 0) { - const bucket = cube.getOrCreateBucket( - cube.positionToZoomedAddress(voxelTargetAddress, labeledZoomStep), - ); - - if (bucket.type === "null") { - return; - } - - const labeledVoxelInBucket = cube.getVoxelOffset(voxelTargetAddress, labeledZoomStep); - const labelMapOfBucket = - labeledVoxelMapOfCopiedVoxel.get(bucket.zoomedAddress) || - new Uint8Array(Constants.BUCKET_WIDTH ** 2).fill(0); - const labeledVoxel2D = [ - labeledVoxelInBucket[dimensionIndices[0]], - labeledVoxelInBucket[dimensionIndices[1]], - ]; - labelMapOfBucket[labeledVoxel2D[0] * Constants.BUCKET_WIDTH + labeledVoxel2D[1]] = 1; - labeledVoxelMapOfCopiedVoxel.set(bucket.zoomedAddress, labelMapOfBucket); - } - } - } - - const thirdDim = dimensionIndices[2]; - const labeledResolution = resolutionInfo.getResolutionByIndexOrThrow(labeledZoomStep); - const directionInverter = action.source === "nextLayer" ? 1 : -1; - let direction = 1; - const useDynamicSpaceDirection = yield* select( - (state) => state.userConfiguration.dynamicSpaceDirection, - ); - - if (useDynamicSpaceDirection) { - const spaceDirectionOrtho = yield* select((state) => state.flycam.spaceDirectionOrtho); - direction = spaceDirectionOrtho[thirdDim]; - } - - const [tx, ty, tz] = Dimensions.transDim(position, activeViewport); - const z = tz; - // When using this tool in more coarse resolutions, the distance to the previous/next slice might be larger than 1 - const previousZ = z + direction * directionInverter * labeledResolution[thirdDim]; - for (let x = tx - halfViewportExtentX; x < tx + halfViewportExtentX; x++) { - for (let y = ty - halfViewportExtentY; y < ty + halfViewportExtentY; y++) { - copyVoxelLabel( - Dimensions.transDim([x, y, previousZ], activeViewport), - Dimensions.transDim([x, y, z], activeViewport), - ); - } - } - - if (labeledVoxelMapOfCopiedVoxel.size === 0) { - const dimensionLabels = ["x", "y", "z"]; - Toast.warning( - `Did not copy any voxels from slice ${dimensionLabels[thirdDim]}=${previousZ}.` + - ` Either no voxels with cell id ${activeCellId} were found or all of the respective voxels were already labeled in the current slice.`, - ); - } - - // applyVoxelMap assumes get3DAddress to be local to the corresponding bucket (so in the labeled resolution as well) - const zInLabeledResolution = - Math.floor(tz / labeledResolution[thirdDim]) % Constants.BUCKET_WIDTH; - applyVoxelMap( - labeledVoxelMapOfCopiedVoxel, - cube, - activeCellId, - getFast3DCoordinateHelper(activeViewport, zInLabeledResolution), - 1, - thirdDim, - false, - 0, - ); - applyLabeledVoxelMapToAllMissingResolutions( - labeledVoxelMapOfCopiedVoxel, - labeledZoomStep, - dimensionIndices, - resolutionInfo, - cube, - activeCellId, - z, - false, - ); - yield* put(finishAnnotationStrokeAction(volumeTracing.tracingId)); -} - const FLOODFILL_PROGRESS_KEY = "FLOODFILL_PROGRESS_KEY"; export function* floodFill(): Saga { yield* take("INITIALIZE_VOLUMETRACING"); @@ -605,6 +467,7 @@ export function* finishLayer( contourTracingMode: ContourMode, overwriteMode: OverwriteMode, labeledZoomStep: number, + activeViewport: OrthoView, ): Saga { if (layer == null || layer.isEmpty()) { return; @@ -617,6 +480,7 @@ export function* finishLayer( contourTracingMode, overwriteMode, labeledZoomStep, + activeViewport, ); } diff --git a/frontend/javascripts/oxalis/model/volumetracing/volumelayer.ts b/frontend/javascripts/oxalis/model/volumetracing/volumelayer.ts index c5ff8dcd250..8defdc018c2 100644 --- a/frontend/javascripts/oxalis/model/volumetracing/volumelayer.ts +++ b/frontend/javascripts/oxalis/model/volumetracing/volumelayer.ts @@ -544,6 +544,10 @@ class VolumeLayer { } const area = sumArea / 2; + if (area == 0) { + return zoomedPositionToGlobalPosition(contourList[0], this.activeResolution); + } + const cx = sumCx / 6 / area; const cy = sumCy / 6 / area; const zoomedPosition = this.get3DCoordinate([cx, cy]); diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index f098bf843f7..2ab7c486fdf 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -39,6 +39,7 @@ import type { Vector3, AnnotationTool, MappingStatus, + OrthoViewWithoutTD, } from "oxalis/constants"; import { ControlModeEnum } from "oxalis/constants"; import type { Matrix4x4 } from "libs/mjs"; @@ -210,6 +211,12 @@ export type Segment = { creationTime: number | null | undefined; }; export type SegmentMap = DiffableMap; + +export type LabelAction = { + centroid: Vector3; // centroid of the label action + plane: OrthoViewWithoutTD; // plane that labeled +}; + export type VolumeTracing = TracingBase & { readonly type: "volume"; // Note that there are also SegmentMaps in `state.localSegmentationData` @@ -217,7 +224,8 @@ export type VolumeTracing = TracingBase & { readonly segments: SegmentMap; readonly maxCellId: number; readonly activeCellId: number; - readonly lastCentroid: Vector3 | null | undefined; + // lastLabelActions[0] is the most recent one + readonly lastLabelActions: Array; readonly contourTracingMode: ContourMode; // Stores points of the currently drawn region in global coordinates readonly contourList: Array; @@ -304,8 +312,6 @@ export type UserConfiguration = { readonly overwriteMode: OverwriteMode; readonly fillMode: FillMode; readonly useLegacyBindings: boolean; - readonly isVolumeInterpolationEnabled: boolean; - readonly volumeInterpolationDepth: number; }; export type RecommendedConfiguration = Partial< UserConfiguration & diff --git a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx index bf4da3ddd86..ebdadabb8c9 100644 --- a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx @@ -1,12 +1,15 @@ -import { Radio, Tooltip, Badge, Space, Popover, RadioChangeEvent, Button } from "antd"; -import { CaretDownOutlined, CaretUpOutlined, ExportOutlined } from "@ant-design/icons"; +import { Radio, Tooltip, Badge, Space, Popover, RadioChangeEvent } from "antd"; +import { ExportOutlined } from "@ant-design/icons"; import { useSelector, useDispatch } from "react-redux"; import React, { useEffect, useState } from "react"; import { LogSliderSetting } from "oxalis/view/components/setting_input_views"; import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; import { convertCellIdToCSS } from "oxalis/view/left-border-tabs/mapping_settings_view"; -import { createCellAction } from "oxalis/model/actions/volumetracing_actions"; +import { + interpolateSegmentationLayerAction, + createCellAction, +} from "oxalis/model/actions/volumetracing_actions"; import { createTreeAction, setMergerModeEnabledAction, @@ -40,13 +43,12 @@ import Constants, { AnnotationTool, OverwriteMode, ToolsWithInterpolationCapabilities, - OrthoViews, } from "oxalis/constants"; import Model from "oxalis/model"; import Store, { OxalisState, VolumeTracing } from "oxalis/store"; -import Dimensions from "oxalis/model/dimensions"; import features from "features"; +import { getInterpolationInfo } from "oxalis/model/sagas/volume/volume_interpolation_saga"; const narrowButtonStyle = { paddingLeft: 10, @@ -230,57 +232,24 @@ function OverwriteModeSwitch({ } function VolumeInterpolationButton() { - const isAllowed = useSelector( - (state: OxalisState) => state.tracing.restrictions.volumeInterpolationAllowed, - ); - - const isEnabled = useSelector( - (state: OxalisState) => state.userConfiguration.isVolumeInterpolationEnabled, - ); - const spaceDirectionOrtho = useSelector((state: OxalisState) => state.flycam.spaceDirectionOrtho); - const activeViewport = useSelector( - (state: OxalisState) => state.viewModeData.plane.activeViewport, - ); + const dispatch = useDispatch(); - const onChange = () => { - Store.dispatch(updateUserSettingAction("isVolumeInterpolationEnabled", !isEnabled)); + const onClick = () => { + dispatch(interpolateSegmentationLayerAction()); }; - let directionIcon = null; - if (isEnabled && isAllowed && activeViewport !== OrthoViews.TDView) { - const thirdDim = Dimensions.thirdDimensionForPlane(activeViewport); - directionIcon = - spaceDirectionOrtho[thirdDim] > 0 ? ( - - ) : ( - - ); - } + const { tooltipTitle, isDisabled } = useSelector((state: OxalisState) => + getInterpolationInfo(state, "Not available since"), + ); return ( - - - - ); + const iconEl = faIcon != null && !this.props.loading ? : null; + const button = + // Differentiate via children != null, since antd uses a different styling for buttons + // with a single icon child (.ant-btn-icon-only will be assigned) + children != null ? ( + + ) : ( + + ); return title != null ? {button} : button; } } diff --git a/frontend/javascripts/test/fixtures/volumetracing_object.ts b/frontend/javascripts/test/fixtures/volumetracing_object.ts index 94a2566bec4..200cd12cd1d 100644 --- a/frontend/javascripts/test/fixtures/volumetracing_object.ts +++ b/frontend/javascripts/test/fixtures/volumetracing_object.ts @@ -15,7 +15,7 @@ const volumeTracing = { activeTool: AnnotationToolEnum.MOVE, maxCellId: 0, contourList: [], - lastCentroid: null, + lastLabelActions: [], tracingId: "tracingId", }; const notEmptyViewportRect = { diff --git a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts index b0ecd5cf95e..53b597b73bf 100644 --- a/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/volumetracing_reducer.spec.ts @@ -155,14 +155,14 @@ test("VolumeTracing should cycle trace/view/brush tool", (t) => { newState = UiReducer(newState, cycleToolAction()); t.is(newState.uiInformation.activeTool, AnnotationToolEnum.MOVE); }); -test("VolumeTracing should update its lastCentroid", (t) => { +test("VolumeTracing should update its lastLabelActions", (t) => { const direction = [4, 6, 9]; const updateDirectionAction = VolumeTracingActions.updateDirectionAction(direction); // Update direction const newState = VolumeTracingReducer(initialState, updateDirectionAction); t.not(newState, initialState); getFirstVolumeTracingOrFail(newState.tracing).map((tracing) => { - t.deepEqual(tracing.lastCentroid, direction); + t.deepEqual(tracing.lastLabelActions[0].centroid, direction); }); }); diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts index a16b842c270..e08abff78ef 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration.spec.ts @@ -9,11 +9,7 @@ import { OrthoViews, OverwriteModeEnum, } from "oxalis/constants"; -import { - __setupOxalis, - createBucketResponseFunction, - getFirstVolumeTracingOrFail, -} from "test/helpers/apiHelpers"; +import { __setupOxalis, createBucketResponseFunction } from "test/helpers/apiHelpers"; import { hasRootSagaCrashed } from "oxalis/model/sagas/root_saga"; import { restartSagaAction, wkReadyAction } from "oxalis/model/actions/actions"; import { updateUserSettingAction } from "oxalis/model/actions/settings_actions"; @@ -28,7 +24,6 @@ const { setActiveCellAction, addToLayerAction, dispatchFloodfillAsync, - copySegmentationLayerAction, startEditingAction, finishEditingAction, setContourTracingModeAction, @@ -324,18 +319,7 @@ test.serial("Executing a floodfill in mag 1 (long operation)", async (t) => { t.context.api.data.reloadAllBuckets(); await assertInitialState(); }); -test.serial( - "Executing copySegmentationLayer with a new segment id should update the maxCellId", - async (t) => { - const newCellId = 13371338; - Store.dispatch(setActiveCellAction(newCellId)); - Store.dispatch(copySegmentationLayerAction()); - // maxCellId should be updated after copySegmentationLayer - getFirstVolumeTracingOrFail(Store.getState().tracing).map((tracing) => { - t.is(tracing.maxCellId, newCellId); - }); - }, -); + test.serial("Brushing/Tracing with a new segment id should update the bucket data", async (t) => { t.context.mocks.Request.sendJSONReceiveArraybufferWithHeaders = createBucketResponseFunction( Uint16Array, diff --git a/frontend/javascripts/types/schemas/user_settings.schema.ts b/frontend/javascripts/types/schemas/user_settings.schema.ts index 075ca7ba473..e734c46ac2e 100644 --- a/frontend/javascripts/types/schemas/user_settings.schema.ts +++ b/frontend/javascripts/types/schemas/user_settings.schema.ts @@ -132,8 +132,6 @@ export const userSettings = { useLegacyBindings: { type: "boolean", }, - isVolumeInterpolationEnabled: { type: "boolean" }, - volumeInterpolationDepth: { type: "number", minimum: 2, maximum: 2 }, ...baseDatasetViewConfiguration, }; export default {