From 4f32e6f2a1defb0403c431aa7cbc3cadf35b2638 Mon Sep 17 00:00:00 2001 From: Florian M Date: Thu, 8 Sep 2022 16:56:50 +0200 Subject: [PATCH] Min-Cut on Agglomerate Graph (#6361) * [WIP] min-cut on agglomerate graph * function stubs * position2 * use mincut from jgrapht * tryo * also send segment positions of min-cut edges * add yarn kill-listeners cmd * fix merge-related compile error * pretty * add context menu entry and action for agglomerate min cut * add api method to backend route * perform min-cut in proofread saga [WIP] * pretty * pretty * weird sleep hack * perform min-cut [WIP] * pretty * remove update group assertion * remove TODOs * update unreleased Changelog * Code review changes Co-authored-by: Philipp Otto Co-authored-by: Arthur Hilbert Co-authored-by: Arthur Hilbert <46814136+Dagobert42@users.noreply.github.com> --- CHANGELOG.unreleased.md | 1 + frontend/javascripts/admin/admin_rest_api.ts | 21 ++++ .../model/actions/skeletontracing_actions.tsx | 9 ++ .../skeletontracing_reducer_helpers.ts | 9 +- .../oxalis/model/sagas/proofread_saga.ts | 104 +++++++++++++++--- .../javascripts/oxalis/view/context_menu.tsx | 21 ++++ package.json | 1 + .../controllers/VolumeTracingController.scala | 16 ++- .../EditableMappingService.scala | 82 +++++++++++++- ...alableminds.webknossos.tracingstore.routes | 1 + 10 files changed, 243 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index a2a012cbf32..1927b106e4a 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -13,6 +13,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - Zarr-based remote dataset import now also works for public AWS S3 endpoints with no credentials. [#6421](https://github.com/scalableminds/webknossos/pull/6421) - Added a context menu option to extract the shortest path between two nodes as a new tree. Select the source node and open the context menu by right-clicking on another node in the same tree. [#6423](https://github.com/scalableminds/webknossos/pull/6423) +- Added a context menu option to separate an agglomerate skeleton using Min-Cut. Activate the Proofreading tool, select the source node and open the context menu by right-clicking on the target node which you would like to separate through Min-Cut. [#6361](https://github.com/scalableminds/webknossos/pull/6361) ### Changed diff --git a/frontend/javascripts/admin/admin_rest_api.ts b/frontend/javascripts/admin/admin_rest_api.ts index 10c47f39be0..9c680afd8bd 100644 --- a/frontend/javascripts/admin/admin_rest_api.ts +++ b/frontend/javascripts/admin/admin_rest_api.ts @@ -2271,3 +2271,24 @@ export function getSynapseTypes( ), ); } + +type MinCutTargetEdge = { + position1: Vector3; + position2: Vector3; + segmentId1: number; + segmentId2: number; +}; +export async function getEdgesForAgglomerateMinCut( + tracingStoreUrl: string, + tracingId: string, + segmentsInfo: Object, +): Promise> { + return doWithToken((token) => + Request.sendJSONReceiveJSON( + `${tracingStoreUrl}/tracings/volume/${tracingId}/agglomerateGraphMinCut?token=${token}`, + { + data: segmentsInfo, + }, + ), + ); +} diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx index ef76833d046..f51cc225414 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx @@ -41,6 +41,7 @@ type DeselectActiveTreeAction = ReturnType; type SetActiveGroupAction = ReturnType; type DeselectActiveGroupAction = ReturnType; export type MergeTreesAction = ReturnType; +export type MinCutAgglomerateAction = ReturnType; type SetTreeNameAction = ReturnType; type SelectNextTreeAction = ReturnType; type SetTreeColorIndexAction = ReturnType; @@ -82,6 +83,7 @@ export type SkeletonTracingAction = | SetActiveTreeByNameAction | DeselectActiveTreeAction | MergeTreesAction + | MinCutAgglomerateAction | SetTreeNameAction | SelectNextTreeAction | SetTreeColorAction @@ -350,6 +352,13 @@ export const mergeTreesAction = (sourceNodeId: number, targetNodeId: number) => targetNodeId, } as const); +export const minCutAgglomerateAction = (sourceNodeId: number, targetNodeId: number) => + ({ + type: "MIN_CUT_AGGLOMERATE", + sourceNodeId, + targetNodeId, + } as const); + export const setTreeNameAction = ( name: string | undefined | null = null, treeId?: number | null | undefined, diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts index a9e0e5598b2..f515f6c6509 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts @@ -225,7 +225,7 @@ export function deleteEdge( targetTree: Tree, targetNode: Node, timestamp: number, -): Maybe<[TreeMap, number]> { +): Maybe<[TreeMap, number | null]> { return getSkeletonTracing(state.tracing).chain((skeletonTracing) => { if (sourceTree.treeId !== targetTree.treeId) { // The two selected nodes are in different trees @@ -254,8 +254,11 @@ export function deleteEdge( timestamp, ); // The treeId of the tree the active node belongs to could have changed - const newActiveTree = findTreeByNodeId(newTrees, sourceNode.id).get(); - return Maybe.Just([newTrees, newActiveTree.treeId]); + const activeNodeId = skeletonTracing.activeNodeId; + const newActiveTreeId = activeNodeId + ? findTreeByNodeId(newTrees, activeNodeId).get().treeId + : null; + return Maybe.Just([newTrees, newActiveTreeId]); }); } diff --git a/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts b/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts index cdd5afbc5c2..273c2734a0c 100644 --- a/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts @@ -4,9 +4,11 @@ import { select, take } from "oxalis/model/sagas/effect-generators"; import { AnnotationToolEnum, MappingStatusEnum, Vector3 } from "oxalis/constants"; import Toast from "libs/toast"; import { + deleteEdgeAction, DeleteEdgeAction, loadAgglomerateSkeletonAction, MergeTreesAction, + MinCutAgglomerateAction, setTreeNameAction, } from "oxalis/model/actions/skeletontracing_actions"; import { @@ -36,7 +38,7 @@ import { getMappingInfo, getResolutionInfo, } from "oxalis/model/accessors/dataset_accessor"; -import { makeMappingEditable } from "admin/admin_rest_api"; +import { getEdgesForAgglomerateMinCut, makeMappingEditable } from "admin/admin_rest_api"; import { setMappingNameAction } from "oxalis/model/actions/settings_actions"; import { getSegmentIdForPositionAsync } from "oxalis/controller/combinations/volume_handlers"; import { loadAdHocMeshAction } from "oxalis/model/actions/segmentation_actions"; @@ -47,11 +49,15 @@ import { getConstructorForElementClass } from "oxalis/model/bucket_data_handling import { Tree } from "oxalis/store"; import { APISegmentationLayer } from "types/api_flow_types"; import { setBusyBlockingInfoAction } from "oxalis/model/actions/ui_actions"; +import _ from "lodash"; export default function* proofreadMapping(): Saga { yield* take("INITIALIZE_SKELETONTRACING"); yield* take("WK_READY"); - yield* takeEvery(["DELETE_EDGE", "MERGE_TREES"], splitOrMergeAgglomerate); + yield* takeEvery( + ["DELETE_EDGE", "MERGE_TREES", "MIN_CUT_AGGLOMERATE"], + splitOrMergeOrMinCutAgglomerate, + ); yield* takeEvery(["PROOFREAD_AT_POSITION"], proofreadAtPosition); } @@ -251,7 +257,15 @@ function* ensureHdf5MappingIsEnabled(layerName: string): Saga { return true; } -function* splitOrMergeAgglomerate(action: MergeTreesAction | DeleteEdgeAction) { +let currentlyPerformingMinCut = false; + +function* splitOrMergeOrMinCutAgglomerate( + action: MergeTreesAction | DeleteEdgeAction | MinCutAgglomerateAction, +) { + // Prevent this method from running recursively into itself during Min-Cut. + if (currentlyPerformingMinCut) { + return; + } const allowUpdate = yield* select((state) => state.tracing.restrictions.allowUpdate); if (!allowUpdate) return; @@ -331,6 +345,18 @@ function* splitOrMergeAgglomerate(action: MergeTreesAction | DeleteEdgeAction) { agglomerateFileZoomstep, ); + const volumeTracingWithEditableMapping = yield* select((state) => + getActiveSegmentationTracing(state), + ); + if ( + volumeTracingWithEditableMapping == null || + volumeTracingWithEditableMapping.mappingName == null + ) { + yield* put(setBusyBlockingInfoAction(false)); + return; + } + const editableMappingId = volumeTracingWithEditableMapping.mappingName; + /* Send the respective split/merge update action to the backend (by pushing to the save queue and saving immediately) */ @@ -364,6 +390,67 @@ function* splitOrMergeAgglomerate(action: MergeTreesAction | DeleteEdgeAction) { agglomerateFileMag, ), ); + } else if (action.type === "MIN_CUT_AGGLOMERATE") { + if (sourceNodeAgglomerateId !== targetNodeAgglomerateId) { + Toast.error("Segments need to be in the same agglomerate to perform a min-cut."); + yield* put(setBusyBlockingInfoAction(false)); + return; + } + + currentlyPerformingMinCut = true; + + const tracingStoreUrl = yield* select((state) => state.tracing.tracingStore.url); + const segmentsInfo = { + segmentPosition1: sourceNodePosition, + segmentPosition2: targetNodePosition, + mag: agglomerateFileMag, + agglomerateId: sourceNodeAgglomerateId, + editableMappingId, + }; + + const edgesToRemove = yield* call( + getEdgesForAgglomerateMinCut, + tracingStoreUrl, + volumeTracingId, + segmentsInfo, + ); + + for (const edge of edgesToRemove) { + let firstNodeId; + let secondNodeId; + for (const node of sourceTree.nodes.values()) { + if (_.isEqual(node.position, edge.position1)) { + firstNodeId = node.id; + } else if (_.isEqual(node.position, edge.position2)) { + secondNodeId = node.id; + } + if (firstNodeId && secondNodeId) { + break; + } + } + + if (!firstNodeId || !secondNodeId) { + Toast.warning( + `Unable to find all nodes for positions ${!firstNodeId ? edge.position1 : null}${ + !secondNodeId ? [", ", edge.position2] : null + } in ${sourceTree.name}.`, + ); + yield* put(setBusyBlockingInfoAction(false)); + currentlyPerformingMinCut = false; + return; + } + + yield* put(deleteEdgeAction(firstNodeId, secondNodeId)); + items.push( + splitAgglomerate( + sourceNodeAgglomerateId, + edge.position1, + edge.position2, + agglomerateFileMag, + ), + ); + } + currentlyPerformingMinCut = false; } if (items.length === 0) { @@ -378,17 +465,6 @@ function* splitOrMergeAgglomerate(action: MergeTreesAction | DeleteEdgeAction) { yield* call([api.data, api.data.reloadBuckets], layerName); - const volumeTracingWithEditableMapping = yield* select((state) => - getActiveSegmentationTracing(state), - ); - if ( - volumeTracingWithEditableMapping == null || - volumeTracingWithEditableMapping.mappingName == null - ) { - yield* put(setBusyBlockingInfoAction(false)); - return; - } - const newSourceNodeAgglomerateId = yield* call( [api.data, api.data.getDataValue], layerName, diff --git a/frontend/javascripts/oxalis/view/context_menu.tsx b/frontend/javascripts/oxalis/view/context_menu.tsx index c19110cb124..2fc0d47d806 100644 --- a/frontend/javascripts/oxalis/view/context_menu.tsx +++ b/frontend/javascripts/oxalis/view/context_menu.tsx @@ -39,6 +39,7 @@ import { import { deleteEdgeAction, mergeTreesAction, + minCutAgglomerateAction, deleteNodeAction, setActiveNodeAction, createTreeAction, @@ -98,6 +99,7 @@ type OwnProps = { type DispatchProps = { deleteEdge: (arg0: number, arg1: number) => void; mergeTrees: (arg0: number, arg1: number) => void; + minCutAgglomerate: (arg0: number, arg1: number) => void; deleteNode: (arg0: number, arg1: number) => void; setActiveNode: (arg0: number) => void; hideTree: (arg0: number) => void; @@ -342,6 +344,7 @@ function NodeContextMenuOptions({ hideContextMenu, deleteEdge, mergeTrees, + minCutAgglomerate, deleteNode, createBranchPoint, deleteBranchpointById, @@ -352,6 +355,9 @@ function NodeContextMenuOptions({ infoRows, allowUpdate, }: NodeContextMenuOptionsProps): JSX.Element { + const isProofreadingActive = useSelector( + (state: OxalisState) => state.uiInformation.activeTool === "PROOFREAD", + ); const dispatch = useDispatch(); if (skeletonTracing == null) { @@ -401,6 +407,17 @@ function NodeContextMenuOptions({ Create Edge & Merge with this Tree{" "} {useLegacyBindings ? shortcutBuilder(["Shift", "Alt", "leftMouse"]) : null} + {isProofreadingActive ? ( + + activeNodeId != null ? minCutAgglomerate(clickedNodeId, activeNodeId) : null + } + > + Perform Min-Cut between these Nodes + + ) : null} ) => ({ dispatch(mergeTreesAction(sourceNodeId, targetNodeId)); }, + minCutAgglomerate(sourceNodeId: number, targetNodeId: number) { + dispatch(minCutAgglomerateAction(sourceNodeId, targetNodeId)); + }, + deleteNode(nodeId: number, treeId: number) { dispatch(deleteNodeAction(nodeId, treeId)); }, diff --git a/package.json b/package.json index 7def64c241e..00a26d64fee 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "build-dev": "node_modules/.bin/webpack", "build-watch": "node_modules/.bin/webpack -w", "listening": "lsof -i:7155,9000,9001,9002", + "kill-listeners": "kill $(lsof -t -i:7155,9000,9001,9002)", "test": "tools/test.sh test --timeout=30s", "test-verbose": "xvfb-run -s '-ac -screen 0 1280x1024x24' tools/test.sh test --timeout=60s --verbose", "test-only": "tools/test.sh test --timeout=30s", diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala index 15b8473c879..e44ff9fc67f 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala @@ -25,6 +25,7 @@ import com.scalableminds.webknossos.tracingstore.slacknotification.TSSlackNotifi import com.scalableminds.webknossos.tracingstore.tracings.editablemapping.{ EditableMappingService, EditableMappingUpdateActionGroup, + MinCutParameters, RemoteFallbackLayer } import com.scalableminds.webknossos.tracingstore.tracings.volume.{ @@ -530,6 +531,20 @@ class VolumeTracingController @Inject()( } } + def agglomerateGraphMinCut(token: Option[String], tracingId: String): Action[MinCutParameters] = + Action.async(validateJson[MinCutParameters]) { implicit request => + log() { + accessTokenService.validateAccess(UserAccessRequest.readTracing(tracingId), urlOrHeaderToken(token, request)) { + for { + tracing <- tracingService.find(tracingId) + _ <- bool2Fox(tracing.getMappingIsEditable) ?~> "Mapping is not editable" + remoteFallbackLayer <- RemoteFallbackLayer.fromVolumeTracing(tracing) + edges <- editableMappingService.agglomerateGraphMinCut(request.body, remoteFallbackLayer, token) + } yield Ok(Json.toJson(edges)) + } + } + } + def updateEditableMapping(token: Option[String], tracingId: String): Action[List[EditableMappingUpdateActionGroup]] = Action.async(validateJson[List[EditableMappingUpdateActionGroup]]) { implicit request => accessTokenService.validateAccess(UserAccessRequest.writeTracing(tracingId), urlOrHeaderToken(token, request)) { @@ -541,7 +556,6 @@ class VolumeTracingController @Inject()( _ <- bool2Fox(request.body.length == 1) ?~> "Editable mapping update group must contain exactly one update group" updateGroup <- request.body.headOption.toFox _ <- bool2Fox(updateGroup.version == currentVersion + 1) ?~> "version mismatch" - _ <- bool2Fox(updateGroup.actions.length == 1) ?~> "Editable mapping update group must contain exactly one update action" _ <- editableMappingService.update(mappingName, updateGroup, updateGroup.version) } yield Ok } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala index 85033c7284d..a969b593500 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/editablemapping/EditableMappingService.scala @@ -1,6 +1,7 @@ package com.scalableminds.webknossos.tracingstore.tracings.editablemapping import java.nio.file.Paths +import java.util import java.util.UUID import com.google.inject.Inject @@ -14,8 +15,6 @@ import com.scalableminds.webknossos.datastore.VolumeTracing.VolumeTracing.Elemen import com.scalableminds.webknossos.datastore.helpers.{NodeDefaults, ProtoGeometryImplicits, SkeletonTracingDefaults} import com.scalableminds.webknossos.datastore.models.DataRequestCollection.DataRequestCollection import com.scalableminds.webknossos.datastore.models._ - -import scala.concurrent.duration._ import com.scalableminds.webknossos.datastore.models.requests.DataServiceDataRequest import com.scalableminds.webknossos.datastore.services.{ BinaryDataService, @@ -31,11 +30,15 @@ import com.scalableminds.webknossos.tracingstore.tracings.{ } import com.typesafe.scalalogging.LazyLogging import net.liftweb.common.Box.tryo -import net.liftweb.common.{Empty, Full} -import play.api.libs.json.{JsObject, JsValue, Json} +import net.liftweb.common.{Box, Empty, Full} +import org.jgrapht.alg.flow.PushRelabelMFImpl +import org.jgrapht.graph.{DefaultWeightedEdge, SimpleWeightedGraph} +import play.api.libs.json.{JsObject, JsValue, Json, OFormat} import scala.collection.mutable import scala.concurrent.ExecutionContext +import scala.concurrent.duration._ +import scala.jdk.CollectionConverters.asScalaSetConverter case class EditableMappingKey( editableMappingId: String, @@ -50,6 +53,29 @@ case class FallbackDataKey( userToken: Option[String] ) +case class MinCutParameters( + segmentPosition1: Vec3Int, + segmentPosition2: Vec3Int, + mag: Vec3Int, + agglomerateId: Long, + editableMappingId: String +) + +object MinCutParameters { + implicit val jsonFormat: OFormat[MinCutParameters] = Json.format[MinCutParameters] +} + +case class EdgeWithPositions( + segmentId1: Long, + segmentId2: Long, + position1: Vec3Int, + position2: Vec3Int +) + +object EdgeWithPositions { + implicit val jsonFormat: OFormat[EdgeWithPositions] = Json.format[EdgeWithPositions] +} + trait FallbackDataHelper { def remoteDatastoreClient: TSRemoteDatastoreClient @@ -564,4 +590,52 @@ class EditableMappingService @Inject()( result <- isosurfaceService.requestIsosurfaceViaActor(isosurfaceRequest) } yield result + def agglomerateGraphMinCut(parameters: MinCutParameters, + remoteFallbackLayer: RemoteFallbackLayer, + userToken: Option[String]): Fox[List[EdgeWithPositions]] = + for { + segmentId1 <- findSegmentIdAtPosition(remoteFallbackLayer, parameters.segmentPosition1, parameters.mag, userToken) + segmentId2 <- findSegmentIdAtPosition(remoteFallbackLayer, parameters.segmentPosition2, parameters.mag, userToken) + mapping <- get(parameters.editableMappingId, remoteFallbackLayer, userToken) + agglomerateGraph <- agglomerateGraphForId(mapping, parameters.agglomerateId, remoteFallbackLayer, userToken) + edgesToCut <- minCut(agglomerateGraph, segmentId1, segmentId2) ?~> "Could not calculate min-cut on agglomerate graph." + edgesWithPositions = annotateEdgesWithPositions(edgesToCut, agglomerateGraph) + } yield edgesWithPositions + + private def minCut(agglomerateGraph: AgglomerateGraph, + segmentId1: Long, + segmentId2: Long): Box[List[(Long, Long)]] = { + val g = new SimpleWeightedGraph[Long, DefaultWeightedEdge](classOf[DefaultWeightedEdge]) + agglomerateGraph.segments.foreach { segmentId => + g.addVertex(segmentId) + } + agglomerateGraph.edges.zip(agglomerateGraph.affinities).foreach { + case (edge, affinity) => + val e = g.addEdge(edge.source, edge.target) + g.setEdgeWeight(e, affinity) + } + tryo { + val minCutImpl = new PushRelabelMFImpl(g) + minCutImpl.calculateMinCut(segmentId1, segmentId2) + val minCutEdges: util.Set[DefaultWeightedEdge] = minCutImpl.getCutEdges + minCutEdges.asScala.toList.map(e => (g.getEdgeSource(e), g.getEdgeTarget(e))) + } + } + + private def annotateEdgesWithPositions(edges: List[(Long, Long)], + agglomerateGraph: AgglomerateGraph): List[EdgeWithPositions] = + edges.map { + case (segmentId1, segmentId2) => + val index1 = agglomerateGraph.segments.indexOf(segmentId1) + val index2 = agglomerateGraph.segments.indexOf(segmentId2) + val position1 = agglomerateGraph.positions(index1) + val position2 = agglomerateGraph.positions(index2) + EdgeWithPositions( + segmentId1, + segmentId2, + position1, + position2 + ) + } + } diff --git a/webknossos-tracingstore/conf/com.scalableminds.webknossos.tracingstore.routes b/webknossos-tracingstore/conf/com.scalableminds.webknossos.tracingstore.routes index f0512d09dff..7f9ebd000ed 100644 --- a/webknossos-tracingstore/conf/com.scalableminds.webknossos.tracingstore.routes +++ b/webknossos-tracingstore/conf/com.scalableminds.webknossos.tracingstore.routes @@ -23,6 +23,7 @@ POST /volume/:tracingId/importVolumeData @com.scalablemin GET /volume/:tracingId/findData @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.findData(token: Option[String], tracingId: String) GET /volume/:tracingId/agglomerateSkeleton/:agglomerateId @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.agglomerateSkeleton(token: Option[String], tracingId: String, agglomerateId: Long) POST /volume/:tracingId/makeMappingEditable @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.makeMappingEditable(token: Option[String], tracingId: String) +POST /volume/:tracingId/agglomerateGraphMinCut @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.agglomerateGraphMinCut(token: Option[String], tracingId: String) POST /volume/getMultiple @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.getMultiple(token: Option[String]) POST /volume/mergedFromIds @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.mergedFromIds(token: Option[String], persist: Boolean) POST /volume/mergedFromContents @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.mergedFromContents(token: Option[String], persist: Boolean)