Skip to content

Commit

Permalink
Lock mapping once the volume annotation was changed by the user (#7549)
Browse files Browse the repository at this point in the history
* make segmentation output layer name for neuron detection configurable

* pin mapping once user did some volume annotation

* some minor clean up

* use and enforce pin mapping action in backend

* undo temporary changes

* reload available agglomerate mappings names if not fetched yet

* add changelog entry

* fix tests

* add test for pin mapping functionality

* clean up code

* fix assertion

* disable proofreading tool in case a mapping is pinned and not editable

* pin mapping upon interpolation & floodfill
- show info toast upon pinning the mapping

* update proofread tool disable message

* fix tests

* Ask user for confirmation before pinning mapping

* pin mapping / no mapping upon first volume annotation action
- Don't pin json mapping
- Don't allow changing state of mapping while pinned is set

* disable merger mode once a mapping is pinned

* don't do quick select if user aborts the pinning of the active mapping

* fix tests

* improve user informing message

* remove unused import

* apply feedback

* apply pr feedback

* properly catch user abort via modal

* Let server saved annotation take precedence over the URL configuration if mapping is pinned

* pin "no active mapping" in case a JSON mapping is active & fix linting

* disable volume annotations tools while json mapping is active and rename pinned to locked

* small code clean up

* rename pinned to locked also in backend + proto

* apply feedback & further replace word "pinned" with "locked"

* further replace "pin" with "locked"

* fix bucket fill ignoring use decision whether to lock the mapping

* format

---------

Co-authored-by: Florian M <florian@scm.io>
Co-authored-by: Philipp Otto <philippotto@users.noreply.github.com>
Co-authored-by: Philipp Otto <philipp.4096@gmail.com>
  • Loading branch information
4 people committed Mar 11, 2024
1 parent 9867294 commit e2f857f
Show file tree
Hide file tree
Showing 22 changed files with 423 additions and 69 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,4 @@ coverage-ts
.bsp
temp-webknossos-schema**
conf/application.conf-e
.env
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
### Changed
- Datasets stored in WKW format are no longer loaded with memory mapping, reducing memory demands. [#7528](https://github.com/scalableminds/webknossos/pull/7528)
- Content Security Policy (CSP) settings are now relaxed by default. To keep stricter CSP rules, add them to your specific `application.conf`. [#7589](https://github.com/scalableminds/webknossos/pull/7589)
- The state of whether a mapping is active and what exact mapping is now locked to the annotation upon the first volume annotation action to ensure future consistent results. Moreover, while a JSON mapping is active, no volume annotation can be done. [#7549](https://github.com/scalableminds/webknossos/pull/7549)
- WEBKNOSSOS now uses Java 21. [#7599](https://github.com/scalableminds/webknossos/pull/7599)
- Email verification is disabled by default. To enable it, set `webKnossos.user.emailVerification.activated` to `true` in your `application.conf`. [#7620](https://github.com/scalableminds/webknossos/pull/7620) [#7621](https://github.com/scalableminds/webknossos/pull/7621)
- Added more documentation for N5 and Neuroglancer precomputed web upload. [#7622](https://github.com/scalableminds/webknossos/pull/7622)
Expand Down
4 changes: 4 additions & 0 deletions frontend/javascripts/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ instead. Only enable this option if you understand its effect. All layers will n
'All trees are currently hidden. You can disable this by toggling the "Skeleton" layer in the layer settings in the left sidebar.',
"tracing.invalid_json_url_hash":
"Cannot parse JSON URL hash. More information was printed to the browser's console.",
"tracing.locked_mapping_info":
"The active volume annotation layer has an active mapping. By mutating the layer, the mapping will be permanently locked and can no longer be changed or disabled. This can only be undone by restoring an older version of this annotation. Are you sure you want to continue?",
"tracing.locked_mapping_confirmed": (mappingName: string) =>
`The mapping ${mappingName} is now locked for this annotation and can no longer be changed or disabled.`,
"layouting.missing_custom_layout_info":
"The annotation views are separated into four classes. Each of them has their own layouts. If you can't find your layout please open the annotation in the correct view mode or just add it here manually.",
"datastore.unknown_type": "Unknown datastore type:",
Expand Down
92 changes: 71 additions & 21 deletions frontend/javascripts/oxalis/model/accessors/tool_accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const getExplanationForDisabledVolume = (
isZoomInvalidForTracing: boolean,
isEditableMappingActive: boolean,
isSegmentationTracingTransformed: boolean,
isJSONMappingActive: boolean,
) => {
if (!isSegmentationTracingVisible) {
return "Volume annotation is disabled since no segmentation tracing layer is enabled. Enable it in the left settings sidebar.";
Expand All @@ -55,6 +56,9 @@ const getExplanationForDisabledVolume = (
if (isSegmentationTracingTransformed) {
return "Volume annotation is disabled because the visible segmentation layer is transformed. Use the left sidebar to render the segmentation layer without any transformations.";
}
if (isJSONMappingActive) {
return "Volume annotation is disabled because a JSON mapping is currently active for the the visible segmentation layer. Disable the JSON mapping to enable volume annotation.";
}

return "Volume annotation is currently disabled.";
};
Expand Down Expand Up @@ -112,13 +116,61 @@ function _getDisabledInfoWhenVolumeIsDisabled(
};
}

function _getDisabledInfoForProofreadTool(
hasSkeleton: boolean,
agglomerateState: AgglomerateState,
isProofReadingToolAllowed: boolean,
isUneditableMappingLocked: boolean,
activeOrganization: APIOrganization | null,
activeUser: APIUser | null | undefined,
) {
// The explanations are prioritized according to effort the user has to put into
// activating proofreading.
// 1) If a non editable mapping is locked to the annotation, proofreading actions are
// not allowed for this annotation.
// 2) If no agglomerate mapping is available (or activated), the user should know
// about this requirement and be able to set it up (this can be the most difficult
// step).
// 3) If a mapping is available, the pricing plan is potentially warned upon.
// 4) In the end, a potentially missing skeleton is warned upon (quite rare, because
// most annotations have a skeleton).
const isDisabled =
!hasSkeleton ||
!agglomerateState.value ||
!isProofReadingToolAllowed ||
isUneditableMappingLocked;
let explanation = "Proofreading actions are not supported after modifying the segmentation.";
if (!isUneditableMappingLocked) {
if (!agglomerateState.value) {
explanation = agglomerateState.reason;
} else if (!isProofReadingToolAllowed) {
explanation = getFeatureNotAvailableInPlanMessage(
PricingPlanEnum.Power,
activeOrganization,
activeUser,
);
} else {
explanation = disabledSkeletonExplanation;
}
} else {
explanation =
"A mapping that does not support proofreading actions is locked to this annotation. Most likely, the annotation layer was modified earlier (e.g. by brushing).";
}
return {
isDisabled,
explanation,
};
}

const getDisabledInfoWhenVolumeIsDisabled = memoizeOne(_getDisabledInfoWhenVolumeIsDisabled);
const getDisabledInfoForProofreadTool = memoizeOne(_getDisabledInfoForProofreadTool);

function _getDisabledInfoFromArgs(
hasSkeleton: boolean,
isZoomStepTooHighForBrushing: boolean,
isZoomStepTooHighForTracing: boolean,
isZoomStepTooHighForFilling: boolean,
isUneditableMappingLocked: boolean,
agglomerateState: AgglomerateState,
genericDisabledExplanation: string,
activeOrganization: APIOrganization | null,
Expand Down Expand Up @@ -170,27 +222,14 @@ function _getDisabledInfoFromArgs(
isDisabled: isZoomStepTooHighForFilling,
explanation: zoomInToUseToolMessage,
},
[AnnotationToolEnum.PROOFREAD]: {
isDisabled: !hasSkeleton || !agglomerateState.value || !isProofReadingToolAllowed,
explanation:
// The explanations are prioritized according to effort the user has to put into
// activating proofreading.
// 1) If no agglomerate mapping is available (or activated), the user should know
// about this requirement and be able to set it up (this can be the most difficult
// step).
// 2) If a mapping is available, the pricing plan is potentially warned upon.
// 3) In the end, a potentially missing skeleton is warned upon (quite rare, because
// most annotations have a skeleton).
agglomerateState.value
? isProofReadingToolAllowed
? disabledSkeletonExplanation
: getFeatureNotAvailableInPlanMessage(
PricingPlanEnum.Power,
activeOrganization,
activeUser,
)
: agglomerateState.reason,
},
[AnnotationToolEnum.PROOFREAD]: getDisabledInfoForProofreadTool(
hasSkeleton,
agglomerateState,
isProofReadingToolAllowed,
isUneditableMappingLocked,
activeOrganization,
activeUser,
),
[AnnotationToolEnum.LINE_MEASUREMENT]: {
isDisabled: false,
explanation: genericDisabledExplanation,
Expand All @@ -211,6 +250,7 @@ export function getDisabledInfoForTools(state: OxalisState): Record<
}
> {
const isInMergerMode = state.temporaryConfiguration.isMergerModeEnabled;
const { activeMappingByLayer } = state.temporaryConfiguration;
const isZoomInvalidForTracing = isMagRestrictionViolated(state);
const hasVolume = state.tracing.volumes.length > 0;
const hasSkeleton = state.tracing.skeleton != null;
Expand All @@ -234,14 +274,22 @@ export function getDisabledInfoForTools(state: OxalisState): Record<
visibleSegmentationLayer.name === segmentationTracingLayer.tracingId;
const isEditableMappingActive =
segmentationTracingLayer != null && !!segmentationTracingLayer.mappingIsEditable;
const isJSONMappingActive =
segmentationTracingLayer != null &&
activeMappingByLayer[segmentationTracingLayer.tracingId]?.mappingType === "JSON" &&
activeMappingByLayer[segmentationTracingLayer.tracingId]?.mappingStatus === "ENABLED";
const genericDisabledExplanation = getExplanationForDisabledVolume(
isSegmentationTracingVisible,
isInMergerMode,
isSegmentationTracingVisibleForMag,
isZoomInvalidForTracing,
isEditableMappingActive,
isSegmentationTracingTransformed,
isJSONMappingActive,
);
const isUneditableMappingLocked =
(segmentationTracingLayer?.mappingIsLocked && !segmentationTracingLayer?.mappingIsEditable) ??
false;

const isVolumeDisabled =
!hasVolume ||
Expand All @@ -250,6 +298,7 @@ export function getDisabledInfoForTools(state: OxalisState): Record<
// this condition doesn't need to be checked here
!isSegmentationTracingVisibleForMag ||
isInMergerMode ||
isJSONMappingActive ||
isSegmentationTracingTransformed;

if (isVolumeDisabled || isEditableMappingActive) {
Expand All @@ -268,6 +317,7 @@ export function getDisabledInfoForTools(state: OxalisState): Record<
isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.BRUSH, state),
isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.TRACE, state),
isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.FILL_CELL, state),
isUneditableMappingLocked,
agglomerateState,
genericDisabledExplanation,
state.activeOrganization,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -527,35 +527,56 @@ export function getMappingInfoForVolumeTracing(
return getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, tracingId);
}

export function hasEditableMapping(
function getVolumeTracingForLayerName(
state: OxalisState,
layerName?: string | null | undefined,
): boolean {
): VolumeTracing | null | undefined {
if (layerName != null) {
// This needs to be checked before calling getRequestedOrDefaultSegmentationTracingLayer,
// as the function will throw an error if layerName is given but a corresponding tracing layer
// does not exist.
const layer = getSegmentationLayerByName(state.dataset, layerName);
const tracing = getTracingForSegmentationLayer(state, layer);

if (tracing == null) return false;
if (tracing == null) return null;
}

const volumeTracing = getRequestedOrDefaultSegmentationTracingLayer(state, layerName);

return volumeTracing;
}

export function hasEditableMapping(
state: OxalisState,
layerName?: string | null | undefined,
): boolean {
const volumeTracing = getVolumeTracingForLayerName(state, layerName);

if (volumeTracing == null) return false;

return !!volumeTracing.mappingIsEditable;
}

export function isMappingLocked(
state: OxalisState,
layerName?: string | null | undefined,
): boolean {
const volumeTracing = getVolumeTracingForLayerName(state, layerName);

if (volumeTracing == null) return false;

return !!volumeTracing.mappingIsLocked;
}

export function isMappingActivationAllowed(
state: OxalisState,
mappingName: string | null | undefined,
layerName?: string | null | undefined,
): boolean {
const isEditableMappingActive = hasEditableMapping(state, layerName);
const isActiveMappingLocked = isMappingLocked(state, layerName);

if (!isEditableMappingActive) return true;
if (!isEditableMappingActive && !isActiveMappingLocked) return true;

const volumeTracing = getRequestedOrDefaultSegmentationTracingLayer(state, layerName);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type RemoveSegmentAction = ReturnType<typeof removeSegmentAction>;
export type DeleteSegmentDataAction = ReturnType<typeof deleteSegmentDataAction>;
export type SetSegmentGroupsAction = ReturnType<typeof setSegmentGroupsAction>;
export type SetMappingIsEditableAction = ReturnType<typeof setMappingIsEditableAction>;
export type SetMappingIsLockedAction = ReturnType<typeof setMappingIsLockedAction>;

export type ComputeQuickSelectForRectAction = ReturnType<typeof computeQuickSelectForRectAction>;
export type MaybePrefetchEmbeddingAction = ReturnType<typeof maybePrefetchEmbeddingAction>;
Expand Down Expand Up @@ -89,6 +90,7 @@ export type VolumeTracingAction =
| SetLargestSegmentIdAction
| SetSelectedSegmentsOrGroupAction
| SetMappingIsEditableAction
| SetMappingIsLockedAction
| InitializeEditableMappingAction
| ComputeQuickSelectForRectAction
| MaybePrefetchEmbeddingAction
Expand All @@ -110,6 +112,8 @@ export const VolumeTracingSaveRelevantActions = [
"SET_MAPPING",
"SET_MAPPING_ENABLED",
"BATCH_UPDATE_GROUPS_AND_SEGMENTS",
"SET_MAPPING_IS_EDITABLE",
"SET_MAPPING_IS_LOCKED",
];

export const VolumeTracingUndoRelevantActions = ["START_EDITING", "COPY_SEGMENTATION_LAYER"];
Expand Down Expand Up @@ -356,6 +360,11 @@ export const setMappingIsEditableAction = () =>
type: "SET_MAPPING_IS_EDITABLE",
}) as const;

export const setMappingIsLockedAction = () =>
({
type: "SET_MAPPING_IS_LOCKED",
}) as const;

export const computeQuickSelectForRectAction = (
startPosition: Vector3,
endPosition: Vector3,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing):
userBoundingBoxes,
mappingName: tracing.mappingName,
mappingIsEditable: tracing.mappingIsEditable,
mappingIsLocked: tracing.mappingIsLocked,
hasSegmentIndex: tracing.hasSegmentIndex || false,
additionalAxes: convertServerAdditionalAxesToFrontEnd(tracing.additionalAxes),
};
Expand Down Expand Up @@ -391,18 +392,27 @@ function VolumeTracingReducer(

case "SET_MAPPING_NAME": {
// Editable mappings cannot be disabled or switched for now
if (volumeTracing.mappingIsEditable) return state;
if (volumeTracing.mappingIsEditable || volumeTracing.mappingIsLocked) return state;

const { mappingName, mappingType } = action;
return setMappingNameReducer(state, volumeTracing, mappingName, mappingType);
}

case "SET_MAPPING_IS_EDITABLE": {
// Editable mappings cannot be disabled or switched for now
if (volumeTracing.mappingIsEditable) return state;
// Editable mappings cannot be disabled or switched for now.
if (volumeTracing.mappingIsEditable || volumeTracing.mappingIsLocked) return state;

// An editable mapping is always locked.
return updateVolumeTracing(state, volumeTracing.tracingId, {
mappingIsEditable: true,
mappingIsLocked: true,
});
}
case "SET_MAPPING_IS_LOCKED": {
if (volumeTracing.mappingIsLocked) return state;

return updateVolumeTracing(state, volumeTracing.tracingId, {
mappingIsLocked: true,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,10 @@ export function setMappingNameReducer(
mappingType: MappingType,
isMappingEnabled: boolean = true,
) {
// Editable mappings cannot be disabled or switched for now
if (volumeTracing.mappingIsEditable) return state;
// Editable mappings or locked mappings cannot be disabled or switched for now
if (volumeTracing.mappingIsEditable || volumeTracing.mappingIsLocked) {
return state;
}
// Only HDF5 mappings are persisted in volume annotations for now
if (mappingType !== "HDF5" || !isMappingEnabled) {
mappingName = null;
Expand Down
16 changes: 16 additions & 0 deletions frontend/javascripts/oxalis/model/sagas/quick_select_saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import performQuickSelectML, {
} from "./quick_select_ml_saga";
import { AnnotationToolEnum } from "oxalis/constants";
import getSceneController from "oxalis/controller/scene_controller_provider";
import { getActiveSegmentationTracing } from "../accessors/volumetracing_accessor";
import { VolumeTracing } from "oxalis/store";
import { ensureMaybeActiveMappingIsLocked } from "./saga_helpers";

function* shouldUseHeuristic() {
const useHeuristic = yield* select((state) => state.userConfiguration.quickSelect.useHeuristic);
Expand All @@ -29,6 +32,19 @@ export default function* listenToQuickSelect(): Saga<void> {
"COMPUTE_QUICK_SELECT_FOR_RECT",
function* guard(action: ComputeQuickSelectForRectAction) {
try {
const volumeTracing: VolumeTracing | null | undefined = yield* select(
getActiveSegmentationTracing,
);
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,
volumeTracing,
);
if (!isMappingLockedIfNeeded) {
return;
}
}
yield* put(setBusyBlockingInfoAction(true, "Selecting segment"));

yield* put(setQuickSelectStateAction("active"));
Expand Down
Loading

0 comments on commit e2f857f

Please sign in to comment.