diff --git a/app/models/annotation/AnnotationService.scala b/app/models/annotation/AnnotationService.scala index 59233b0843..3b333d7bf7 100755 --- a/app/models/annotation/AnnotationService.scala +++ b/app/models/annotation/AnnotationService.scala @@ -179,7 +179,8 @@ class AnnotationService @Inject()( mappingName = mappingName, mags = magsRestricted.map(vec3IntToProto), hasSegmentIndex = Some(fallbackLayer.isEmpty || fallbackLayerHasSegmentIndex), - additionalAxes = AdditionalAxis.toProto(additionalAxes) + additionalAxes = AdditionalAxis.toProto(additionalAxes), + volumeBucketDataHasChanged = Some(false) ) } diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 781ac41355..caf749eeb3 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -45,6 +45,9 @@ export type SetSegmentGroupsAction = ReturnType; export type SetExpandedSegmentGroupsAction = ReturnType; export type SetHasEditableMappingAction = ReturnType; export type SetMappingIsLockedAction = ReturnType; +export type SetVolumeBucketDataHasChangedAction = ReturnType< + typeof setVolumeBucketDataHasChangedAction +>; export type ComputeQuickSelectForRectAction = ReturnType; export type ComputeQuickSelectForPointAction = ReturnType; @@ -99,6 +102,7 @@ export type VolumeTracingAction = | FineTuneQuickSelectAction | CancelQuickSelectAction | ConfirmQuickSelectAction + | SetVolumeBucketDataHasChangedAction | BatchUpdateGroupsAndSegmentsAction; export const VolumeTracingSaveRelevantActions = [ @@ -437,3 +441,9 @@ export const batchUpdateGroupsAndSegmentsAction = (actions: BatchableUpdateSegme export const cancelQuickSelectAction = () => ({ type: "CANCEL_QUICK_SELECT" }) as const; export const confirmQuickSelectAction = () => ({ type: "CONFIRM_QUICK_SELECT" }) as const; + +export const setVolumeBucketDataHasChangedAction = (tracingId: string) => + ({ + type: "SET_VOLUME_BUCKET_DATA_HAS_CHANGED", + tracingId, + }) as const; diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts index e7ffa4f8da..ec4ea3835a 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts @@ -269,6 +269,7 @@ export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing): mappingName: tracing.mappingName, hasEditableMapping: tracing.hasEditableMapping, mappingIsLocked: tracing.mappingIsLocked, + volumeBucketDataHasChanged: tracing.volumeBucketDataHasChanged, hasSegmentIndex: tracing.hasSegmentIndex || false, additionalAxes: convertServerAdditionalAxesToFrontEnd(tracing.additionalAxes), }; @@ -377,6 +378,12 @@ function VolumeTracingReducer( return expandSegmentParents(state, action); } + case "SET_VOLUME_BUCKET_DATA_HAS_CHANGED": { + return updateVolumeTracing(state, action.tracingId, { + volumeBucketDataHasChanged: true, + }); + } + default: // pass } diff --git a/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts b/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts index d863505aed..791d51e921 100644 --- a/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts @@ -15,7 +15,7 @@ import performQuickSelectML from "./quick_select_ml_saga"; import getSceneController from "oxalis/controller/scene_controller_provider"; import { getActiveSegmentationTracing } from "../accessors/volumetracing_accessor"; import type { VolumeTracing } from "oxalis/store"; -import { ensureMaybeActiveMappingIsLocked } from "./saga_helpers"; +import { requestBucketModificationInVolumeTracing } from "./saga_helpers"; function* shouldUseHeuristic() { const useHeuristic = yield* select((state) => state.userConfiguration.quickSelect.useHeuristic); @@ -32,11 +32,11 @@ export default function* listenToQuickSelect(): Saga { ); if (volumeTracing) { // As changes to the volume layer will be applied, the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call( - ensureMaybeActiveMappingIsLocked, + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, volumeTracing, ); - if (!isMappingLockedIfNeeded) { + if (!isModificationAllowed) { return; } } diff --git a/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts b/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts index 2ebc695b15..abf7b049b1 100644 --- a/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts +++ b/frontend/javascripts/oxalis/model/sagas/saga_helpers.ts @@ -9,7 +9,10 @@ import { call, put, takeEvery } from "typed-redux-saga"; import Toast from "libs/toast"; import { Store } from "oxalis/singletons"; import type { ActionPattern } from "@redux-saga/types"; -import { setMappingIsLockedAction } from "../actions/volumetracing_actions"; +import { + setMappingIsLockedAction, + setVolumeBucketDataHasChangedAction, +} from "../actions/volumetracing_actions"; import { MappingStatusEnum } from "oxalis/constants"; export function* takeEveryUnlessBusy

( @@ -113,4 +116,33 @@ export function* ensureMaybeActiveMappingIsLocked( } } +export function* requestBucketModificationInVolumeTracing( + volumeTracing: VolumeTracing, +): Saga { + /* + * Should be called when the modification of bucket data is about to happen. If + * the saga returns false, the modification should be cancelled (this happens if + * the user is not okay with locking the mapping). + * + * In detail: When the bucket data of a volume tracing is supposed to be mutated, we need to do + * two things: + * 1) ensure that the current mapping (or no mapping) is locked so that the mapping is not + * changed later (this would lead to inconsistent data). If the mapping state is not yet + * locked, the user is asked whether it is okay to lock it. + * If the user confirms this, the mapping is locked and we can continue with (2). If the + * user denies the locking request, the original bucket mutation will NOT be executed. + * 2) volumeTracing.volumeBucketDataHasChanged will bet set to true if the user didn't + * deny the request for locking the mapping. + */ + + const { isMappingLockedIfNeeded } = yield* call(ensureMaybeActiveMappingIsLocked, volumeTracing); + if (!isMappingLockedIfNeeded) { + return false; + } + + // Mark that bucket data has changed + yield* put(setVolumeBucketDataHasChangedAction(volumeTracing.tracingId)); + return true; +} + export default {}; 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 55f47e5772..3d1690b936 100644 --- a/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/volume/volume_interpolation_saga.ts @@ -38,7 +38,7 @@ import { Model, api } from "oxalis/singletons"; import type { OxalisState } from "oxalis/store"; import { call, put } from "typed-redux-saga"; import { createVolumeLayer, getBoundingBoxForViewport, labelWithVoxelBuffer2D } from "./helpers"; -import { ensureMaybeActiveMappingIsLocked } from "../saga_helpers"; +import { requestBucketModificationInVolumeTracing } from "../saga_helpers"; /* * This saga is capable of doing segment interpolation between two slices. @@ -436,8 +436,11 @@ export default function* maybeInterpolateSegmentationLayer(): Saga { return; } // As the interpolation will be applied, the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call(ensureMaybeActiveMappingIsLocked, volumeTracing); - if (!isMappingLockedIfNeeded) { + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, + volumeTracing, + ); + if (!isModificationAllowed) { return; } diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx index f1770e8598..12e1919ac3 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx @@ -82,7 +82,7 @@ import { select, take } from "oxalis/model/sagas/effect-generators"; import listenToMinCut from "oxalis/model/sagas/min_cut_saga"; import listenToQuickSelect from "oxalis/model/sagas/quick_select_saga"; import { - ensureMaybeActiveMappingIsLocked, + requestBucketModificationInVolumeTracing, takeEveryUnlessBusy, } from "oxalis/model/sagas/saga_helpers"; import { @@ -223,11 +223,11 @@ export function* editVolumeLayerAsync(): Saga { const activeCellId = yield* select((state) => enforceActiveVolumeTracing(state).activeCellId); // As changes to the volume layer will be applied, the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call( - ensureMaybeActiveMappingIsLocked, + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, volumeTracing, ); - if (!isMappingLockedIfNeeded) { + if (!isModificationAllowed) { continue; } @@ -453,11 +453,11 @@ export function* floodFill(): Saga { } // As the flood fill will be applied to the volume layer, // the potentially existing mapping should be locked to ensure a consistent state. - const { isMappingLockedIfNeeded } = yield* call( - ensureMaybeActiveMappingIsLocked, + const isModificationAllowed = yield* call( + requestBucketModificationInVolumeTracing, volumeTracing, ); - if (!isMappingLockedIfNeeded) { + if (!isModificationAllowed) { continue; } yield* put(setBusyBlockingInfoAction(true, "Floodfill is being computed.")); @@ -603,6 +603,7 @@ export function* finishLayer( yield* put(registerLabelPointAction(layer.getUnzoomedCentroid())); } + export function* ensureToolIsAllowedInMag(): Saga { yield* take("INITIALIZE_VOLUMETRACING"); diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index deca802926..26fb97c0dd 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -264,6 +264,7 @@ export type VolumeTracing = TracingBase & { readonly hasEditableMapping?: boolean; readonly mappingIsLocked?: boolean; readonly hasSegmentIndex: boolean; + readonly volumeBucketDataHasChanged?: boolean; }; export type ReadOnlyTracing = TracingBase & { readonly type: "readonly"; diff --git a/frontend/javascripts/oxalis/view/context_menu.tsx b/frontend/javascripts/oxalis/view/context_menu.tsx index 21ddce3d0c..634de6a7c5 100644 --- a/frontend/javascripts/oxalis/view/context_menu.tsx +++ b/frontend/javascripts/oxalis/view/context_menu.tsx @@ -131,6 +131,7 @@ import { import { hideContextMenuAction, setActiveUserBoundingBoxId } from "oxalis/model/actions/ui_actions"; import { getDisabledInfoForTools } from "oxalis/model/accessors/tool_accessor"; import FastTooltip from "components/fast_tooltip"; +import { LoadMeshMenuItemLabel } from "./right-border-tabs/segments_tab/load_mesh_menu_item_label"; type ContextMenuContextValue = React.MutableRefObject | null; export const ContextMenuContext = createContext(null); @@ -1179,7 +1180,9 @@ function getNoNodeContextMenuOptions(props: NoNodeContextMenuProps): ItemType[] segmentationLayerName, mappingInfo, ), - label: "Load Mesh (precomputed)", + label: ( + + ), }; const computeMeshAdHocItem = { key: "compute-mesh-adhc", @@ -1434,12 +1437,13 @@ function ContextMenuInner(propsWithInputRef: Props) { maybeClickedMeshId != null ? maybeClickedMeshId : segmentIdAtPosition; const wasSegmentOrMeshClicked = clickedSegmentOrMeshId > 0; - const { dataset, tracing, flycam } = useSelector((state: OxalisState) => state); + const dataset = useSelector((state: OxalisState) => state.dataset); useEffect(() => { Store.dispatch(ensureSegmentIndexIsLoadedAction(visibleSegmentationLayer?.name)); }, [visibleSegmentationLayer]); - const isSegmentIndexAvailable = useSelector((state: OxalisState) => - getMaybeSegmentIndexAvailability(state.dataset, visibleSegmentationLayer?.name), + const isSegmentIndexAvailable = getMaybeSegmentIndexAvailability( + dataset, + visibleSegmentationLayer?.name, ); const mappingName: string | null | undefined = useSelector((state: OxalisState) => { if (volumeTracing?.mappingName != null) return volumeTracing?.mappingName; @@ -1453,6 +1457,7 @@ function ContextMenuInner(propsWithInputRef: Props) { const isLoadingVolumeAndBB = [isLoadingMessage, isLoadingMessage]; const [segmentVolumeLabel, boundingBoxInfoLabel] = useFetch( async () => { + const { tracing, flycam } = Store.getState(); // The value that is returned if the context menu is closed is shown if it's still loading if (contextMenuPosition == null || !wasSegmentOrMeshClicked) return isLoadingVolumeAndBB; if (visibleSegmentationLayer == null || !isSegmentIndexAvailable) return []; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/load_mesh_menu_item_label.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/load_mesh_menu_item_label.tsx new file mode 100644 index 0000000000..0faa65c529 --- /dev/null +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/load_mesh_menu_item_label.tsx @@ -0,0 +1,37 @@ +import { WarningOutlined } from "@ant-design/icons"; +import FastTooltip from "components/fast_tooltip"; +import type { VolumeTracing } from "oxalis/store"; +import type { APIMeshFile } from "types/api_flow_types"; + +type Props = { + currentMeshFile: APIMeshFile | null | undefined; + volumeTracing: VolumeTracing | null | undefined; +}; + +export function LoadMeshMenuItemLabel({ currentMeshFile, volumeTracing }: Props) { + const showWarning = + volumeTracing?.volumeBucketDataHasChanged ?? + // For older annotations, volumeBucketDataHasChanged can be undefined. + // In that case, we still want to show a warning if no proofreading was + // done, but the mapping is still locked (i.e., the user brushed). + (!volumeTracing?.hasEditableMapping && volumeTracing?.mappingIsLocked); + + return ( + + + Load Mesh (precomputed) + + {showWarning && ( + + + + )} + + ); +} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx index dd4ea4ac38..04ba5ef673 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx @@ -40,6 +40,7 @@ import { V4 } from "libs/mjs"; import { ChangeColorMenuItemContent } from "components/color_picker"; import type { MenuItemType } from "antd/es/menu/interface"; import { withMappingActivationConfirmation } from "./segments_view_helper"; +import { LoadMeshMenuItemLabel } from "./load_mesh_menu_item_label"; import type { AdditionalCoordinate } from "types/api_flow_types"; import { getAdditionalCoordinatesAsString, @@ -80,8 +81,10 @@ const getLoadPrecomputedMeshMenuItem = ( hideContextMenu: (_ignore?: any) => void, layerName: string | null | undefined, mappingInfo: ActiveMappingInfo, + activeVolumeTracing: VolumeTracing | null | undefined, ) => { const mappingName = currentMeshFile != null ? currentMeshFile.mappingName : undefined; + return { key: "loadPrecomputedMesh", disabled: !currentMeshFile, @@ -114,16 +117,10 @@ const getLoadPrecomputedMeshMenuItem = ( mappingInfo, ), label: ( - - Load Mesh (precomputed) - + ), }; }; @@ -429,6 +426,7 @@ function _SegmentListItem({ hideContextMenu, visibleSegmentationLayer != null ? visibleSegmentationLayer.name : null, mappingInfo, + activeVolumeTracing, ), getComputeMeshAdHocMenuItem( segment, diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts index 34bfd2c302..206b45c64d 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga.spec.ts @@ -40,7 +40,10 @@ mockRequire("libs/toast", { const { setupSavingForTracingType } = require("oxalis/model/sagas/save_saga"); const { editVolumeLayerAsync, finishLayer } = require("oxalis/model/sagas/volumetracing_saga"); -const { ensureMaybeActiveMappingIsLocked } = require("oxalis/model/sagas/saga_helpers"); +const { + requestBucketModificationInVolumeTracing, + ensureMaybeActiveMappingIsLocked, +} = require("oxalis/model/sagas/saga_helpers"); const VolumeLayer = require("oxalis/model/volumetracing/volumelayer").default; @@ -437,11 +440,11 @@ test("VolumeTracingSaga should lock an active mapping upon first volume annotati mag: [1, 1, 1], zoomStep: 0, }); - // Test whether nested saga ensureMaybeActiveMappingIsLocked is called. + // Test whether nested saga requestBucketModificationInVolumeTracing is called. expectValueDeepEqual( t, saga.next(ACTIVE_CELL_ID), - call(ensureMaybeActiveMappingIsLocked, volumeTracing), + call(requestBucketModificationInVolumeTracing, volumeTracing), ); }); diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md index 222a9ed027..48087c9ed2 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md @@ -1318,6 +1318,7 @@ Generated by [AVA](https://avajs.dev). typ: 'Volume', userBoundingBoxes: [], version: 0, + volumeBucketDataHasChanged: false, zoomLevel: 1, } @@ -1408,6 +1409,7 @@ Generated by [AVA](https://avajs.dev). typ: 'Volume', userBoundingBoxes: [], version: 0, + volumeBucketDataHasChanged: false, zoomLevel: 1, }, } diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap index 818cad4f78..8a0b4fd925 100644 Binary files a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap and b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap differ diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index e3fb3502ed..958651b32e 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -875,6 +875,10 @@ export type ServerVolumeTracing = ServerTracingBase & { hasEditableMapping?: boolean; mappingIsLocked?: boolean; hasSegmentIndex?: boolean; + // volumeBucketDataHasChanged is automatically set to true by the back-end + // once a bucket was mutated. There is no need to send an explicit UpdateAction + // for that. + volumeBucketDataHasChanged?: boolean; }; export type ServerTracing = ServerSkeletonTracing | ServerVolumeTracing; export type ServerEditableMapping = { diff --git a/webknossos-datastore/proto/VolumeTracing.proto b/webknossos-datastore/proto/VolumeTracing.proto index 0f06affd9a..495d1a481f 100644 --- a/webknossos-datastore/proto/VolumeTracing.proto +++ b/webknossos-datastore/proto/VolumeTracing.proto @@ -48,6 +48,7 @@ message VolumeTracing { repeated AdditionalCoordinateProto editPositionAdditionalCoordinates = 21; repeated AdditionalAxisProto additionalAxes = 22; // Additional axes for which this tracing is defined optional bool mappingIsLocked = 23; // user may not select another mapping (e.g. because they have already mutated buckets) + optional bool volumeBucketDataHasChanged = 24; // The volume bucket data has been edited at least once } message SegmentGroup { diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala index 5dfe5bb672..f78c53cd0b 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeTracingService.scala @@ -210,7 +210,7 @@ class VolumeTracingService @Inject()( ) ?~> "failed to update segment index" } yield () } - } yield volumeTracing + } yield volumeTracing.copy(volumeBucketDataHasChanged = Some(true)) override def editableMappingTracingId(tracing: VolumeTracing, tracingId: String): Option[String] = if (tracing.getHasEditableMapping) Some(tracingId) else None @@ -281,7 +281,7 @@ class VolumeTracingService @Inject()( } yield () })) _ <- segmentIndexBuffer.flush() - } yield volumeTracing + } yield volumeTracing.copy(volumeBucketDataHasChanged = Some(true)) private def assertMagIsValid(tracing: VolumeTracing, mag: Vec3Int): Fox[Unit] = if (tracing.mags.nonEmpty) {