diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index d9a328e829d..53981ff1dd1 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -15,6 +15,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Segment statistics are now available for ND datasets. [#7411](https://github.com/scalableminds/webknossos/pull/7411) - Added support for uploading N5 and Neuroglancer Precomputed datasets. [#7578](https://github.com/scalableminds/webknossos/pull/7578) - Webknossos can now open ND Zarr datasets with arbitrary axis orders (not limited to `**xyz` anymore). [#7592](https://github.com/scalableminds/webknossos/pull/7592) +- Added support for skeleton annotations within datasets that have transformed layers. The skeleton nodes will move according to the transforms when rendering a specific layer natively. Also, downloading visible trees can be done by incorporating the current transforms. However, note that the back-end export does not take transforms into account. [#7588](https://github.com/scalableminds/webknossos/pull/7588) - Added a new "Split from all neighboring segments" feature for the proofreading mode. [#7611](https://github.com/scalableminds/webknossos/pull/7611) - If storage scan is enabled, the measured used storage is now displayed in the dashboard’s dataset detail view. [#7677](https://github.com/scalableminds/webknossos/pull/7677) - Prepared support to download full stl meshes via the HTTP api. [#7587](https://github.com/scalableminds/webknossos/pull/7587) diff --git a/frontend/javascripts/admin/dataset/composition_wizard/02_upload_files.tsx b/frontend/javascripts/admin/dataset/composition_wizard/02_upload_files.tsx index 5d5d7a86a67..6bd0ac87973 100644 --- a/frontend/javascripts/admin/dataset/composition_wizard/02_upload_files.tsx +++ b/frontend/javascripts/admin/dataset/composition_wizard/02_upload_files.tsx @@ -188,8 +188,8 @@ async function parseNmlFiles(fileList: FileList): Promise ); } if (node1 != null && node2 != null) { - sourcePoints.push(node1.position); - targetPoints.push(node2.position); + sourcePoints.push(node1.untransformedPosition); + targetPoints.push(node2.untransformedPosition); } } } diff --git a/frontend/javascripts/oxalis/api/api_latest.ts b/frontend/javascripts/oxalis/api/api_latest.ts index d1b23c5ef09..473ad71aa69 100644 --- a/frontend/javascripts/oxalis/api/api_latest.ts +++ b/frontend/javascripts/oxalis/api/api_latest.ts @@ -60,6 +60,7 @@ import { getFlatTreeGroups, getTreeGroupsMap, mapGroups, + getNodePosition, } from "oxalis/model/accessors/skeletontracing_accessor"; import { getActiveCellId, @@ -160,6 +161,7 @@ import type { OxalisState, SegmentGroup, Segment, + MutableNode, } from "oxalis/store"; import Store from "oxalis/store"; import type { ToastStyle } from "libs/toast"; @@ -1006,9 +1008,9 @@ class TracingApi { */ centerNode = (nodeId?: number): void => { const skeletonTracing = assertSkeleton(Store.getState().tracing); - getNodeAndTree(skeletonTracing, nodeId).map(([, node]) => - Store.dispatch(setPositionAction(node.position)), - ); + getNodeAndTree(skeletonTracing, nodeId).map(([, node]) => { + return Store.dispatch(setPositionAction(getNodePosition(node, Store.getState()))); + }); }; /** @@ -1052,23 +1054,26 @@ class TracingApi { * Measures the length of the given tree and returns the length in nanometer and in voxels. */ measureTreeLength(treeId: number): [number, number] { - const skeletonTracing = assertSkeleton(Store.getState().tracing); + const state = Store.getState(); + const skeletonTracing = assertSkeleton(state.tracing); const tree = skeletonTracing.trees[treeId]; if (!tree) { throw new Error(`Tree with id ${treeId} not found.`); } - const datasetScale = Store.getState().dataset.dataSource.scale; + const datasetScale = state.dataset.dataSource.scale; // Pre-allocate vectors let lengthNmAcc = 0; let lengthVxAcc = 0; + const getPos = (node: Readonly) => getNodePosition(node, state); + for (const edge of tree.edges.all()) { const sourceNode = tree.nodes.get(edge.source); const targetNode = tree.nodes.get(edge.target); - lengthNmAcc += V3.scaledDist(sourceNode.position, targetNode.position, datasetScale); - lengthVxAcc += V3.length(V3.sub(sourceNode.position, targetNode.position)); + lengthNmAcc += V3.scaledDist(getPos(sourceNode), getPos(targetNode), datasetScale); + lengthVxAcc += V3.length(V3.sub(getPos(sourceNode), getPos(targetNode))); } return [lengthNmAcc, lengthVxAcc]; @@ -1145,14 +1150,17 @@ class TracingApi { }); priorityQueue.queue([sourceNodeId, 0]); + const state = Store.getState(); + const getPos = (node: Readonly) => getNodePosition(node, state); + while (priorityQueue.length > 0) { const [nextNodeId, distance] = priorityQueue.dequeue(); - const nextNodePosition = sourceTree.nodes.get(nextNodeId).position; + const nextNodePosition = getPos(sourceTree.nodes.get(nextNodeId)); // Calculate the distance to all neighbours and update the distances. for (const { source, target } of sourceTree.edges.getEdgesForNode(nextNodeId)) { const neighbourNodeId = source === nextNodeId ? target : source; - const neighbourPosition = sourceTree.nodes.get(neighbourNodeId).position; + const neighbourPosition = getPos(sourceTree.nodes.get(neighbourNodeId)); const neighbourDistance = distance + V3.scaledDist(nextNodePosition, neighbourPosition, datasetScale); @@ -2625,11 +2633,7 @@ class UtilsApi { */ registerOverwrite( actionName: string, - overwriteFunction: ( - store: S, - next: (action: A) => void, - originalAction: A, - ) => void | Promise, + overwriteFunction: (store: S, next: (action: A) => void, originalAction: A) => A | Promise, ) { return overwriteAction(actionName, overwriteFunction); } diff --git a/frontend/javascripts/oxalis/api/api_v2.ts b/frontend/javascripts/oxalis/api/api_v2.ts index 097f26cb452..f4685b90a59 100644 --- a/frontend/javascripts/oxalis/api/api_v2.ts +++ b/frontend/javascripts/oxalis/api/api_v2.ts @@ -21,6 +21,7 @@ import { getActiveNode, getActiveTree, getTree, + getNodePosition, } from "oxalis/model/accessors/skeletontracing_accessor"; import { setActiveCellAction } from "oxalis/model/actions/volumetracing_actions"; import { getActiveCellId } from "oxalis/model/accessors/volumetracing_accessor"; @@ -346,9 +347,10 @@ class TracingApi { * api.tracing.centerNode() */ centerNode = (nodeId?: number): void => { - const skeletonTracing = assertSkeleton(Store.getState().tracing); + const state = Store.getState(); + const skeletonTracing = assertSkeleton(state.tracing); getNodeAndTree(skeletonTracing, nodeId).map(([, node]) => - Store.dispatch(setPositionAction(node.position)), + Store.dispatch(setPositionAction(getNodePosition(node, state))), ); }; @@ -841,7 +843,7 @@ class UtilsApi { */ registerOverwrite( actionName: string, - overwriteFunction: (store: S, next: (action: A) => void, originalAction: A) => void, + overwriteFunction: (store: S, next: (action: A) => void, originalAction: A) => A | Promise, ) { overwriteAction(actionName, overwriteFunction); } diff --git a/frontend/javascripts/oxalis/constants.ts b/frontend/javascripts/oxalis/constants.ts index 534eb89ea4d..311569236b1 100644 --- a/frontend/javascripts/oxalis/constants.ts +++ b/frontend/javascripts/oxalis/constants.ts @@ -376,5 +376,9 @@ export enum BLEND_MODES { } export const Identity4x4 = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); -export const IdentityTransform = { type: "affine", affineMatrix: Identity4x4 } as const; +export const IdentityTransform = { + type: "affine", + affineMatrix: Identity4x4, + affineMatrixInv: Identity4x4, +} as const; export const EMPTY_OBJECT = {} as const; diff --git a/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.ts b/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.ts index f3d754c0002..aa4047ee4c8 100644 --- a/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.ts +++ b/frontend/javascripts/oxalis/controller/combinations/skeleton_handlers.ts @@ -16,6 +16,8 @@ import { getActiveNode, getNodeAndTree, getNodeAndTreeOrNull, + getNodePosition, + untransformNodePosition, } from "oxalis/model/accessors/skeletontracing_accessor"; import { getInputCatcherRect, @@ -197,10 +199,10 @@ export function moveNode( op(vector[1] * zoomFactor * scaleFactor[1]), op(vector[2] * zoomFactor * scaleFactor[2]), ]; - const [x, y, z] = activeNode.position; + const [x, y, z] = getNodePosition(activeNode, state); Store.dispatch( setNodePositionAction( - [x + delta[0], y + delta[1], z + delta[2]], + untransformNodePosition([x + delta[0], y + delta[1], z + delta[2]], state), activeNode.id, activeTree.treeId, ), @@ -213,7 +215,11 @@ export function finishNodeMovement(nodeId: number) { getSkeletonTracing(Store.getState().tracing).map((skeletonTracing) => getNodeAndTree(skeletonTracing, nodeId).map(([activeTree, node]) => { Store.dispatch( - setNodePositionAction(V3.round(node.position, [0, 0, 0]), node.id, activeTree.treeId), + setNodePositionAction( + V3.round(node.untransformedPosition, [0, 0, 0]), + node.id, + activeTree.treeId, + ), ); }), ); @@ -228,15 +234,16 @@ export function setWaypoint( const activeNodeMaybe = getActiveNode(skeletonTracing); const rotation = getRotationOrtho(activeViewport); // set the new trace direction - activeNodeMaybe.map((activeNode) => - Store.dispatch( + activeNodeMaybe.map((activeNode) => { + const activeNodePosition = getNodePosition(activeNode, Store.getState()); + return Store.dispatch( setDirectionAction([ - position[0] - activeNode.position[0], - position[1] - activeNode.position[1], - position[2] - activeNode.position[2], + position[0] - activeNodePosition[0], + position[1] - activeNodePosition[1], + position[2] - activeNodePosition[2], ]), - ), - ); + ); + }); const state = Store.getState(); // Create a new tree automatically if the corresponding setting is true and allowed const createNewTree = @@ -276,7 +283,7 @@ function addNode( Store.dispatch( createNodeAction( - position, + untransformNodePosition(position, state), state.flycam.additionalCoordinates, rotation, OrthoViewToNumber[Store.getState().viewModeData.plane.activeViewport], @@ -293,12 +300,10 @@ function addNode( if (center) { // we created a new node, so get a new reference from the current store state const newState = Store.getState(); - enforce(getActiveNode)(newState.tracing.skeleton).map( - ( - newActiveNode, // Center the position of the active node without modifying the "third" dimension (see centerPositionAnimated) - ) => - // This is important because otherwise the user cannot continue to trace until the animation is over - api.tracing.centerPositionAnimated(newActiveNode.position, true), + enforce(getActiveNode)(newState.tracing.skeleton).map((newActiveNode) => + // Center the position of the active node without modifying the "third" dimension (see centerPositionAnimated) + // This is important because otherwise the user cannot continue to trace until the animation is over + api.tracing.centerPositionAnimated(getNodePosition(newActiveNode, state), true), ); } diff --git a/frontend/javascripts/oxalis/controller/td_controller.tsx b/frontend/javascripts/oxalis/controller/td_controller.tsx index 983c58d1732..c0fc0091102 100644 --- a/frontend/javascripts/oxalis/controller/td_controller.tsx +++ b/frontend/javascripts/oxalis/controller/td_controller.tsx @@ -26,7 +26,7 @@ import { moveTDViewYAction, moveTDViewByVectorWithoutTimeTrackingAction, } from "oxalis/model/actions/view_mode_actions"; -import { getActiveNode } from "oxalis/model/accessors/skeletontracing_accessor"; +import { getActiveNode, getNodePosition } from "oxalis/model/accessors/skeletontracing_accessor"; import { voxelToNm } from "oxalis/model/scaleinfo"; import CameraController from "oxalis/controller/camera_controller"; import PlaneView from "oxalis/view/plane_view"; @@ -128,7 +128,7 @@ class TDController extends React.PureComponent { // This happens because the selection of the node does not trigger a call to setTargetAndFixPosition directly. // Thus we do it manually whenever the active node changes. getActiveNode(this.props.tracing.skeleton).map((activeNode) => - this.setTargetAndFixPosition(activeNode.position), + this.setTargetAndFixPosition(getNodePosition(activeNode, Store.getState())), ); } } diff --git a/frontend/javascripts/oxalis/controller/viewmodes/arbitrary_controller.tsx b/frontend/javascripts/oxalis/controller/viewmodes/arbitrary_controller.tsx index bde028f109c..17d8240caeb 100644 --- a/frontend/javascripts/oxalis/controller/viewmodes/arbitrary_controller.tsx +++ b/frontend/javascripts/oxalis/controller/viewmodes/arbitrary_controller.tsx @@ -3,7 +3,12 @@ import type { ModifierKeys } from "libs/input"; import { InputKeyboard, InputKeyboardNoLoop, InputMouse } from "libs/input"; import type { Matrix4x4 } from "libs/mjs"; import { V3 } from "libs/mjs"; -import { getActiveNode, getMaxNodeId } from "oxalis/model/accessors/skeletontracing_accessor"; +import { + getActiveNode, + getMaxNodeId, + getNodePosition, + untransformNodePosition, +} from "oxalis/model/accessors/skeletontracing_accessor"; import { getRotation, getPosition, getMoveOffset3d } from "oxalis/model/accessors/flycam_accessor"; import { getViewportScale } from "oxalis/model/accessors/view_mode_accessor"; import { listenToStoreProperty } from "oxalis/model/helpers/listener_helpers"; @@ -198,14 +203,19 @@ class ArbitraryController extends React.PureComponent { }, // Recenter active node s: () => { - const skeletonTracing = Store.getState().tracing.skeleton; + const state = Store.getState(); + const skeletonTracing = state.tracing.skeleton; if (!skeletonTracing) { return; } getActiveNode(skeletonTracing).map((activeNode) => - api.tracing.centerPositionAnimated(activeNode.position, false, activeNode.rotation), + api.tracing.centerPositionAnimated( + getNodePosition(activeNode, state), + false, + activeNode.rotation, + ), ); }, ".": () => this.nextNode(true), @@ -357,12 +367,18 @@ class ArbitraryController extends React.PureComponent { if (!Store.getState().temporaryConfiguration.flightmodeRecording) { return; } - - const position = getPosition(Store.getState().flycam); - const rotation = getRotation(Store.getState().flycam); - const additionalCoordinates = Store.getState().flycam.additionalCoordinates; + const state = Store.getState(); + const position = getPosition(state.flycam); + const rotation = getRotation(state.flycam); + const additionalCoordinates = state.flycam.additionalCoordinates; Store.dispatch( - createNodeAction(position, additionalCoordinates, rotation, constants.ARBITRARY_VIEW, 0), + createNodeAction( + untransformNodePosition(position, state), + additionalCoordinates, + rotation, + constants.ARBITRARY_VIEW, + 0, + ), ); } diff --git a/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts b/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts index 47efc845700..cacfadb7ba1 100644 --- a/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts +++ b/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts @@ -5,10 +5,19 @@ import { listenToStoreProperty } from "oxalis/model/helpers/listener_helpers"; import shaderEditor from "oxalis/model/helpers/shader_editor"; import { Store } from "oxalis/singletons"; import _ from "lodash"; +import { getTransformsForSkeletonLayer } from "oxalis/model/accessors/dataset_accessor"; +import { M4x4 } from "libs/mjs"; +import { + generateCalculateTpsOffsetFunction, + generateTpsInitialization, +} from "oxalis/shaders/thin_plate_spline.glsl"; +import TPS3D from "libs/thin_plate_spline"; class EdgeShader { material: THREE.RawShaderMaterial; uniforms: Uniforms = {}; + scaledTps: TPS3D | null = null; + oldVertexShaderCode: string | null = null; constructor(treeColorTexture: THREE.DataTexture) { this.setupUniforms(treeColorTexture); @@ -23,6 +32,7 @@ class EdgeShader { } setupUniforms(treeColorTexture: THREE.DataTexture): void { + const state = Store.getState(); this.uniforms = { activeTreeId: { value: NaN, @@ -30,7 +40,20 @@ class EdgeShader { treeColors: { value: treeColorTexture, }, + datasetScale: { + value: state.dataset.dataSource.scale, + }, + }; + + const dataset = Store.getState().dataset; + const nativelyRenderedLayerName = + Store.getState().datasetConfiguration.nativelyRenderedLayerName; + this.uniforms["transform"] = { + value: M4x4.transpose( + getTransformsForSkeletonLayer(dataset, nativelyRenderedLayerName).affineMatrix, + ), }; + const { additionalCoordinates } = Store.getState().flycam; _.each(additionalCoordinates, (_val, idx) => { @@ -48,12 +71,50 @@ class EdgeShader { }, true, ); + + listenToStoreProperty( + (storeState) => + getTransformsForSkeletonLayer( + storeState.dataset, + storeState.datasetConfiguration.nativelyRenderedLayerName, + ), + (skeletonTransforms) => { + const transforms = skeletonTransforms; + const { affineMatrix } = transforms; + + const scaledTps = transforms.type === "thin_plate_spline" ? transforms.scaledTps : null; + + if (scaledTps) { + this.scaledTps = scaledTps; + } else { + this.scaledTps = null; + } + + this.uniforms["transform"].value = M4x4.transpose(affineMatrix); + + this.recomputeVertexShader(); + }, + ); } getMaterial(): THREE.RawShaderMaterial { return this.material; } + recomputeVertexShader() { + const newVertexShaderCode = this.getVertexShader(); + + // Comparing to this.material.vertexShader does not work. The code seems + // to be modified by a third party. + if (this.oldVertexShaderCode != null && this.oldVertexShaderCode === newVertexShaderCode) { + return; + } + + this.oldVertexShaderCode = newVertexShaderCode; + this.material.vertexShader = newVertexShaderCode; + this.material.needsUpdate = true; + } + getVertexShader(): string { const { additionalCoordinates } = Store.getState().flycam; @@ -67,15 +128,23 @@ uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform float activeTreeId; uniform sampler2D treeColors; +uniform vec3 datasetScale; + +uniform mat4 transform; -<% _.each(additionalCoordinates || [], (_coord, idx) => { %> +<% if (tpsTransform != null) { %> + <%= generateTpsInitialization({Skeleton: tpsTransform}, "Skeleton") %> + <%= generateCalculateTpsOffsetFunction("Skeleton", true) %> +<% } %> + +<% _.range(additionalCoordinateLength).map((idx) => { %> uniform float currentAdditionalCoord_<%= idx %>; <% }) %> in vec3 position; in float treeId; -<% _.each(additionalCoordinates || [], (_coord, idx) => { %> +<% _.range(additionalCoordinateLength).map((idx) => { %> in float additionalCoord_<%= idx %>; <% }) %> @@ -83,12 +152,16 @@ out float alpha; void main() { alpha = 1.0; - <% _.each(additionalCoordinates || [], (_coord, idx) => { %> + <% _.range(additionalCoordinateLength).map((idx) => { %> if (additionalCoord_<%= idx %> != currentAdditionalCoord_<%= idx %>) { alpha = 0.; } <% }) %> + <% if (tpsTransform != null) { %> + initializeTPSArraysForSkeleton(); + <% } %> + ivec2 treeIdToTextureCoordinate = ivec2( mod(treeId, ${COLOR_TEXTURE_WIDTH_FIXED}), mod(floor(treeId / ${COLOR_TEXTURE_WIDTH_FIXED}), ${COLOR_TEXTURE_WIDTH_FIXED}) @@ -101,9 +174,22 @@ void main() { return; } - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + <% if (tpsTransform != null) { %> + vec3 tpsOffset = calculateTpsOffsetForSkeleton(position); + vec4 transformedCoord = vec4(position + tpsOffset, 1.); + <% } else { %> + vec4 transformedCoord = transform * vec4(position, 1.); + <% } %> + gl_Position = projectionMatrix * modelViewMatrix * transformedCoord; + + color = rgba.rgb; -}`)({ additionalCoordinates }); +}`)({ + additionalCoordinateLength: (additionalCoordinates || []).length, + tpsTransform: this.scaledTps, + generateTpsInitialization, + generateCalculateTpsOffsetFunction, + }); } getFragmentShader(): string { diff --git a/frontend/javascripts/oxalis/geometries/materials/node_shader.ts b/frontend/javascripts/oxalis/geometries/materials/node_shader.ts index 5efe4c52313..b990f8d42d7 100644 --- a/frontend/javascripts/oxalis/geometries/materials/node_shader.ts +++ b/frontend/javascripts/oxalis/geometries/materials/node_shader.ts @@ -8,6 +8,14 @@ import { Store } from "oxalis/singletons"; import shaderEditor from "oxalis/model/helpers/shader_editor"; import _ from "lodash"; import { formatNumberAsGLSLFloat } from "oxalis/shaders/utils.glsl"; +import { getTransformsForSkeletonLayer } from "oxalis/model/accessors/dataset_accessor"; +import { M4x4 } from "libs/mjs"; +import { + generateCalculateTpsOffsetFunction, + generateTpsInitialization, +} from "oxalis/shaders/thin_plate_spline.glsl"; +import TPS3D from "libs/thin_plate_spline"; + export const NodeTypes = { INVALID: 0.0, NORMAL: 1.0, @@ -19,6 +27,8 @@ export const COLOR_TEXTURE_WIDTH_FIXED = COLOR_TEXTURE_WIDTH.toFixed(1); class NodeShader { material: THREE.RawShaderMaterial; uniforms: Uniforms = {}; + scaledTps: TPS3D | null = null; + oldVertexShaderCode: string | null = null; constructor(treeColorTexture: THREE.DataTexture) { this.setupUniforms(treeColorTexture); @@ -44,6 +54,9 @@ class NodeShader { value: getZoomValue(state.flycam), }, datasetScale: { + value: state.dataset.dataSource.scale, + }, + datasetScaleMin: { value: getBaseVoxel(state.dataset.dataSource.scale), }, overrideParticleSize: { @@ -106,12 +119,59 @@ class NodeShader { }, true, ); + + const dataset = Store.getState().dataset; + const nativelyRenderedLayerName = + Store.getState().datasetConfiguration.nativelyRenderedLayerName; + + const { affineMatrix } = getTransformsForSkeletonLayer(dataset, nativelyRenderedLayerName); + this.uniforms["transform"] = { + value: M4x4.transpose(affineMatrix), + }; + + listenToStoreProperty( + (storeState) => + getTransformsForSkeletonLayer( + storeState.dataset, + storeState.datasetConfiguration.nativelyRenderedLayerName, + ), + (skeletonTransforms) => { + const transforms = skeletonTransforms; + const { affineMatrix } = transforms; + + const scaledTps = transforms.type === "thin_plate_spline" ? transforms.scaledTps : null; + + if (scaledTps) { + this.scaledTps = scaledTps; + } else { + this.scaledTps = null; + } + + this.uniforms["transform"].value = M4x4.transpose(affineMatrix); + + this.recomputeVertexShader(); + }, + ); } getMaterial(): THREE.RawShaderMaterial { return this.material; } + recomputeVertexShader() { + const newVertexShaderCode = this.getVertexShader(); + + // Comparing to this.material.vertexShader does not work. The code seems + // to be modified by a third party. + if (this.oldVertexShaderCode != null && this.oldVertexShaderCode === newVertexShaderCode) { + return; + } + + this.oldVertexShaderCode = newVertexShaderCode; + this.material.vertexShader = newVertexShaderCode; + this.material.needsUpdate = true; + } + getVertexShader(): string { const { additionalCoordinates } = Store.getState().flycam; @@ -124,7 +184,8 @@ out vec3 color; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; uniform float planeZoomFactor; -uniform float datasetScale; +uniform vec3 datasetScale; +uniform float datasetScaleMin; uniform float viewportScale; uniform float activeNodeId; uniform float activeTreeId; @@ -135,7 +196,14 @@ uniform int isTouch; // bool that is used during picking and indicates whether t uniform float highlightCommentedNodes; uniform float viewMode; -<% _.each(additionalCoordinates || [], (_coord, idx) => { %> +uniform mat4 transform; + +<% if (tpsTransform != null) { %> + <%= generateTpsInitialization({Skeleton: tpsTransform}, "Skeleton") %> + <%= generateCalculateTpsOffsetFunction("Skeleton", true) %> +<% } %> + +<% _.range(additionalCoordinateLength).map((idx) => { %> uniform float currentAdditionalCoord_<%= idx %>; <% }) %> @@ -144,7 +212,7 @@ uniform sampler2D treeColors; in float radius; in vec3 position; -<% _.each(additionalCoordinates || [], (_coord, idx) => { %> +<% _.range(additionalCoordinateLength).map((idx) => { %> in float additionalCoord_<%= idx %>; <% }) %> @@ -185,12 +253,16 @@ vec3 shiftHue(vec3 color, float shiftValue) { } void main() { - <% _.each(additionalCoordinates || [], (_coord, idx) => { %> + <% _.range(additionalCoordinateLength).map((idx) => { %> if (additionalCoord_<%= idx %> != currentAdditionalCoord_<%= idx %>) { return; } <% }) %> + <% if (tpsTransform != null) { %> + initializeTPSArraysForSkeleton(); + <% } %> + ivec2 treeIdToTextureCoordinate = ivec2( mod(treeId, ${COLOR_TEXTURE_WIDTH_FIXED}), mod(floor(treeId / ${COLOR_TEXTURE_WIDTH_FIXED}), ${COLOR_TEXTURE_WIDTH_FIXED}) @@ -209,14 +281,20 @@ void main() { return; } - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + <% if (tpsTransform != null) { %> + vec3 tpsOffset = calculateTpsOffsetForSkeleton(position); + vec4 transformedCoord = vec4(position + tpsOffset, 1.); + <% } else { %> + vec4 transformedCoord = transform * vec4(position, 1.); + <% } %> + gl_Position = projectionMatrix * modelViewMatrix * transformedCoord; // NODE RADIUS if (overrideNodeRadius == 1) { gl_PointSize = overrideParticleSize; } else { gl_PointSize = max( - radius / planeZoomFactor / datasetScale, + radius / planeZoomFactor / datasetScaleMin, overrideParticleSize ) * viewportScale; } @@ -264,7 +342,12 @@ void main() { gl_PointSize *= 2.0; } -}`)({ additionalCoordinates }); +}`)({ + additionalCoordinateLength: (additionalCoordinates || []).length, + tpsTransform: this.scaledTps, + generateTpsInitialization, + generateCalculateTpsOffsetFunction, + }); } getFragmentShader(): string { diff --git a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts index de9b41924be..1b4f5898354 100644 --- a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts +++ b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts @@ -818,7 +818,6 @@ class PlaneMaterialFactory { this.uniforms[`${name}_transform`].value = invertAndTranspose(affineMatrix); const hasTransform = !_.isEqual(affineMatrix, Identity4x4); - console.log(`${name}_has_transform`, hasTransform); this.uniforms[`${name}_has_transform`] = { value: hasTransform, }; diff --git a/frontend/javascripts/oxalis/geometries/skeleton.ts b/frontend/javascripts/oxalis/geometries/skeleton.ts index 46399be8402..faa17d9a79d 100644 --- a/frontend/javascripts/oxalis/geometries/skeleton.ts +++ b/frontend/javascripts/oxalis/geometries/skeleton.ts @@ -13,6 +13,7 @@ import NodeShader, { import Store from "oxalis/throttled_store"; import * as Utils from "libs/utils"; import { type AdditionalCoordinate } from "types/api_flow_types"; +import { UpdateActionNode } from "oxalis/model/sagas/update_actions"; const MAX_CAPACITY = 1000; @@ -219,10 +220,16 @@ class Skeleton { const geometry = new THREE.BufferGeometry() as BufferGeometryWithBufferAttributes; helper.setAttributes(geometry, capacity); const mesh = helper.buildMesh(geometry, material); + // Frustum culling is disabled because nodes that are transformed + // wouldn't be culled correctly. + // In basic testing, culling didn't provide a noticable performance + // improvement (tested with 500k skeleton nodes). + mesh.frustumCulled = false; this.rootGroup.add(mesh); if (helper.supportsPicking) { const pickingMesh = helper.buildMesh(geometry, material); + pickingMesh.frustumCulled = false; this.pickingNode.add(pickingMesh); } @@ -511,14 +518,16 @@ class Skeleton { /** * Creates a new node in a WebGL buffer. */ - createNode(treeId: number, node: Node) { + createNode(treeId: number, node: Node | UpdateActionNode) { const id = this.combineIds(node.id, treeId); this.create( id, this.nodes, ({ buffer, index }: BufferPosition): Array => { const attributes = buffer.geometry.attributes; - attributes.position.set(node.position, index * 3); + const untransformedPosition = + "untransformedPosition" in node ? node.untransformedPosition : node.position; + attributes.position.set(untransformedPosition, index * 3); if (node.additionalCoordinates) { for (const idx of _.range(0, node.additionalCoordinates.length)) { @@ -649,8 +658,8 @@ class Skeleton { const positionAttribute = attributes.position; const treeIdAttribute = attributes.treeId; - positionAttribute.set(source.position, index * 6); - positionAttribute.set(target.position, index * 6 + 3); + positionAttribute.set(source.untransformedPosition, index * 6); + positionAttribute.set(target.untransformedPosition, index * 6 + 3); treeIdAttribute.set([treeId, treeId], index * 2); const changedAttributes = []; diff --git a/frontend/javascripts/oxalis/merger_mode.ts b/frontend/javascripts/oxalis/merger_mode.ts index e091a555348..86f93303a2e 100644 --- a/frontend/javascripts/oxalis/merger_mode.ts +++ b/frontend/javascripts/oxalis/merger_mode.ts @@ -1,10 +1,21 @@ import _ from "lodash"; -import type { DeleteNodeUpdateAction, NodeWithTreeId } from "oxalis/model/sagas/update_actions"; -import type { TreeMap, SkeletonTracing, OxalisState } from "oxalis/store"; +import type { + DeleteNodeUpdateAction, + NodeWithTreeId, + UpdateActionNode, +} from "oxalis/model/sagas/update_actions"; +import type { TreeMap, SkeletonTracing, OxalisState, StoreType } from "oxalis/store"; import type { Vector3 } from "oxalis/constants"; import { cachedDiffTrees } from "oxalis/model/sagas/skeletontracing_saga"; -import { getVisibleSegmentationLayer } from "oxalis/model/accessors/dataset_accessor"; -import { getSkeletonTracing } from "oxalis/model/accessors/skeletontracing_accessor"; +import { + getInverseSegmentationTransformer, + getVisibleSegmentationLayer, +} from "oxalis/model/accessors/dataset_accessor"; +import { + getNodePosition, + getSkeletonTracing, + transformNodePosition, +} from "oxalis/model/accessors/skeletontracing_accessor"; import Store from "oxalis/throttled_store"; import { api } from "oxalis/singletons"; import messages from "messages"; @@ -104,22 +115,22 @@ function getAllNodesWithTreeId(): Array { // Do not create nodes if they are set outside of segments. async function createNodeOverwrite( - _store: OxalisState, + store: StoreType, call: (action: Action) => void, action: CreateNodeAction, mergerModeState: MergerModeState, -) { +): Promise { const { segmentationLayerName } = mergerModeState; if (!segmentationLayerName) { - return; + return action; } + const { position: untransformedPosition, additionalCoordinates } = action; - const { position, additionalCoordinates } = action; - const segmentId = await api.data.getDataValue( + const segmentId = await getSegmentId( + store.getState(), segmentationLayerName, - position, - null, + untransformedPosition, additionalCoordinates, ); @@ -135,6 +146,7 @@ async function createNodeOverwrite( api.tracing.centerActiveNode(); } } + return action; } /* React to added nodes. Look up the segment id at the node position and @@ -143,7 +155,7 @@ async function onCreateNode( mergerModeState: MergerModeState, nodeId: number, treeId: number, - position: Vector3, + untransformedPosition: Vector3, additionalCoordinates: AdditionalCoordinate[] | null, updateMapping: boolean = true, ) { @@ -153,10 +165,10 @@ async function onCreateNode( return; } - const segmentId = await api.data.getDataValue( + const segmentId = await getSegmentId( + Store.getState(), segmentationLayerName, - position, - null, + untransformedPosition, additionalCoordinates, ); @@ -179,6 +191,32 @@ async function onCreateNode( } } +async function getSegmentId( + state: OxalisState, + segmentationLayerName: string, + untransformedPosition: Vector3, + additionalCoordinates: AdditionalCoordinate[] | null, +) { + // Calculate where the node is actually rendered. + const transformedNodePosition = transformNodePosition(untransformedPosition, state); + + // Apply the inverse of the segmentation transform to know where to look up + // the voxel value. + const inverseSegmentationTransform = getInverseSegmentationTransformer( + state, + segmentationLayerName, + ); + const segmentPosition = inverseSegmentationTransform(transformedNodePosition); + + const segmentId = await api.data.getDataValue( + segmentationLayerName, + segmentPosition, + null, + additionalCoordinates, + ); + return segmentId; +} + /* This function decreases the number of nodes associated with the segment the passed node belongs to. * If the count reaches 0, the segment is removed from the mapping and the mapping is updated. */ @@ -206,15 +244,22 @@ async function onDeleteNode( } } -async function onUpdateNode(mergerModeState: MergerModeState, node: NodeWithTreeId) { - const { position, id, treeId } = node; +async function onUpdateNode(mergerModeState: MergerModeState, node: UpdateActionNode) { + const { position: untransformedPosition, id, treeId } = node; const { segmentationLayerName, nodeSegmentMap } = mergerModeState; if (segmentationLayerName == null) { return; } - const segmentId = await api.data.getDataValue(segmentationLayerName, position); + const state = Store.getState(); + + const segmentId = await getSegmentId( + state, + segmentationLayerName, + untransformedPosition, + state.flycam.additionalCoordinates, + ); if (nodeSegmentMap[id] !== segmentId) { // If the segment of the node changed, it is like the node got deleted and a copy got created somewhere else. @@ -224,7 +269,14 @@ async function onUpdateNode(mergerModeState: MergerModeState, node: NodeWithTree } if (segmentId != null && segmentId > 0) { - await onCreateNode(mergerModeState, id, treeId, position, node.additionalCoordinates, false); + await onCreateNode( + mergerModeState, + id, + treeId, + untransformedPosition, + node.additionalCoordinates, + false, + ); } else if (nodeSegmentMap[id] != null) { // The node is not inside a segment anymore. Thus we delete it from the nodeSegmentMap. delete nodeSegmentMap[id]; @@ -240,8 +292,14 @@ function updateState(mergerModeState: MergerModeState, skeletonTracing: Skeleton for (const action of diff) { switch (action.name) { case "createNode": { - const { treeId, id: nodeId, position } = action.value; - onCreateNode(mergerModeState, nodeId, treeId, position, action.value.additionalCoordinates); + const { treeId, id: nodeId, position: untransformedPosition } = action.value; + onCreateNode( + mergerModeState, + nodeId, + treeId, + untransformedPosition, + action.value.additionalCoordinates, + ); break; } @@ -306,23 +364,32 @@ async function mergeSegmentsOfAlreadyExistingTrees( const [segMinVec, segMaxVec] = api.data.getBoundingBox(segmentationLayerName); const setSegmentationOfNode = async (node: NodeWithTreeId) => { - const pos = node.position; + const transformedNodePosition = getNodePosition(node, Store.getState()); const { treeId } = node; + // Apply the inverse of the segmentation transform to know where to look up + // the voxel value. + const state = Store.getState(); + const inverseSegmentationTransform = getInverseSegmentationTransformer( + state, + segmentationLayerName, + ); + const segmentPosition = inverseSegmentationTransform(transformedNodePosition); + // Skip nodes outside segmentation if ( - pos[0] < segMinVec[0] || - pos[1] < segMinVec[1] || - pos[2] < segMinVec[2] || - pos[0] >= segMaxVec[0] || - pos[1] >= segMaxVec[1] || - pos[2] >= segMaxVec[2] + segmentPosition[0] < segMinVec[0] || + segmentPosition[1] < segMinVec[1] || + segmentPosition[2] < segMinVec[2] || + segmentPosition[0] >= segMaxVec[0] || + segmentPosition[1] >= segMaxVec[1] || + segmentPosition[2] >= segMaxVec[2] ) { // The node is not in bounds of the segmentation return; } - const segmentId = await api.data.getDataValue(segmentationLayerName, pos); + const segmentId = await api.data.getDataValue(segmentationLayerName, segmentPosition); if (segmentId != null && segmentId > 0) { // Store the segment id @@ -351,7 +418,7 @@ async function mergeSegmentsOfAlreadyExistingTrees( function resetState(mergerModeState: Partial = {}) { const state = Store.getState(); - const visibleLayer = getVisibleSegmentationLayer(Store.getState()); + const visibleLayer = getVisibleSegmentationLayer(state); const segmentationLayerName = visibleLayer != null ? visibleLayer.name : null; const defaults = { treeIdToRepresentativeSegmentId: {}, @@ -399,7 +466,7 @@ export async function enableMergerMode( ); // Register for single CREATE_NODE actions to avoid setting nodes outside of segments unsubscribeFunctions.push( - api.utils.registerOverwrite("CREATE_NODE", (store, next, originalAction) => + api.utils.registerOverwrite("CREATE_NODE", (store, next, originalAction) => createNodeOverwrite(store, next, originalAction as CreateNodeAction, mergerModeState), ), ); diff --git a/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts b/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts index d03929fb125..29d5690ebd5 100644 --- a/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts @@ -7,6 +7,7 @@ import type { APIDataset, APIMaybeUnimportedDataset, APISegmentationLayer, + APISkeletonLayer, ElementClass, } from "types/api_flow_types"; import type { @@ -30,9 +31,11 @@ import { convertToDenseResolution, ResolutionInfo } from "../helpers/resolution_ import MultiKeyMap from "libs/multi_key_map"; import { chainTransforms, + createAffineTransformFromMatrix, createThinPlateSplineTransform, invertTransform, Transform, + transformPointUnscaled, } from "../helpers/transformation_helpers"; function _getResolutionInfo(resolutions: Array): ResolutionInfo { @@ -701,7 +704,7 @@ function _getOriginalTransformsForLayerOrNull( if (type === "affine") { const nestedMatrix = transformation.matrix; - return { type, affineMatrix: nestedToFlatMatrix(nestedMatrix) }; + return createAffineTransformFromMatrix(nestedMatrix); } else if (type === "thin_plate_spline") { const { source, target } = transformation.correspondences; @@ -716,9 +719,12 @@ function _getOriginalTransformsForLayerOrNull( function _getTransformsForLayerOrNull( dataset: APIDataset, - layer: APIDataLayer, + layer: APIDataLayer | APISkeletonLayer, nativelyRenderedLayerName: string | null, ): Transform | null { + if (layer.category === "skeleton") { + return getTransformsForSkeletonLayerOrNull(dataset, nativelyRenderedLayerName); + } const layerTransforms = _getOriginalTransformsForLayerOrNull(dataset, layer); if (nativelyRenderedLayerName == null) { @@ -763,7 +769,7 @@ function memoizeWithThreeKeys(fn: (a: A, b: B, c: C) => T) { export const getTransformsForLayerOrNull = memoizeWithThreeKeys(_getTransformsForLayerOrNull); export function getTransformsForLayer( dataset: APIDataset, - layer: APIDataLayer, + layer: APIDataLayer | APISkeletonLayer, nativelyRenderedLayerName: string | null, ): Transform { return ( @@ -772,6 +778,41 @@ export function getTransformsForLayer( ); } +function _getTransformsForSkeletonLayerOrNull( + dataset: APIDataset, + nativelyRenderedLayerName: string | null, +): Transform | null { + if (nativelyRenderedLayerName == null) { + // No layer is requested to be rendered natively. We can use + // each layer's transforms as is. The skeleton layer doesn't have + // a transforms property currently, which is why we return null. + return null; + } + + // Compute the inverse of the layer that should be rendered natively + const nativeLayer = getLayerByName(dataset, nativelyRenderedLayerName, true); + const transformsOfNativeLayer = _getOriginalTransformsForLayerOrNull(dataset, nativeLayer); + + if (transformsOfNativeLayer == null) { + // The inverse of no transforms, are no transforms + return null; + } + + return invertTransform(transformsOfNativeLayer); +} + +export const getTransformsForSkeletonLayerOrNull = memoizeOne(_getTransformsForSkeletonLayerOrNull); + +export function getTransformsForSkeletonLayer( + dataset: APIDataset, + nativelyRenderedLayerName: string | null, +): Transform { + return ( + getTransformsForSkeletonLayerOrNull(dataset, nativelyRenderedLayerName || null) || + IdentityTransform + ); +} + function _getTransformsPerLayer( dataset: APIDataset, nativelyRenderedLayerName: string | null, @@ -788,15 +829,22 @@ function _getTransformsPerLayer( export const getTransformsPerLayer = memoizeOne(_getTransformsPerLayer); +export function getInverseSegmentationTransformer( + state: OxalisState, + segmentationLayerName: string, +) { + const { dataset } = state; + const { nativelyRenderedLayerName } = state.datasetConfiguration; + const layer = getLayerByName(dataset, segmentationLayerName); + const segmentationTransforms = getTransformsForLayer(dataset, layer, nativelyRenderedLayerName); + return transformPointUnscaled(invertTransform(segmentationTransforms)); +} + export const hasDatasetTransforms = memoizeOne((dataset: APIDataset) => { const layers = dataset.dataSource.dataLayers; return layers.some((layer) => _getOriginalTransformsForLayerOrNull(dataset, layer) != null); }); -export function nestedToFlatMatrix(matrix: [Vector4, Vector4, Vector4, Vector4]): Matrix4x4 { - return [...matrix[0], ...matrix[1], ...matrix[2], ...matrix[3]]; -} - export function flatToNestedMatrix(matrix: Matrix4x4): [Vector4, Vector4, Vector4, Vector4] { return [ matrix.slice(0, 4) as Vector4, diff --git a/frontend/javascripts/oxalis/model/accessors/skeletontracing_accessor.ts b/frontend/javascripts/oxalis/model/accessors/skeletontracing_accessor.ts index 9068b0be864..962c99f6df5 100644 --- a/frontend/javascripts/oxalis/model/accessors/skeletontracing_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/skeletontracing_accessor.ts @@ -15,12 +15,18 @@ import type { TreeGroup, TreeGroupTypeFlat, Node, + OxalisState, } from "oxalis/store"; import { findGroup, MISSING_GROUP_ID, } from "oxalis/view/right-border-tabs/tree_hierarchy_view_helpers"; -import type { TreeType } from "oxalis/constants"; +import type { TreeType, Vector3 } from "oxalis/constants"; +import { + getTransformsForSkeletonLayer, + getTransformsForSkeletonLayerOrNull, +} from "./dataset_accessor"; +import { invertTransform, transformPointUnscaled } from "../helpers/transformation_helpers"; export function getSkeletonTracing(tracing: Tracing): Maybe { if (tracing.skeleton != null) { @@ -192,6 +198,36 @@ export function getNodeAndTreeOrNull( node: null, }); } + +export function isSkeletonLayerTransformed(state: OxalisState) { + return ( + getTransformsForSkeletonLayerOrNull( + state.dataset, + state.datasetConfiguration.nativelyRenderedLayerName, + ) != null + ); +} + +export function getNodePosition(node: Node, state: OxalisState): Vector3 { + return transformNodePosition(node.untransformedPosition, state); +} + +export function transformNodePosition(position: Vector3, state: OxalisState): Vector3 { + const dataset = state.dataset; + const nativelyRenderedLayerName = state.datasetConfiguration.nativelyRenderedLayerName; + + const currentTransforms = getTransformsForSkeletonLayer(dataset, nativelyRenderedLayerName); + return transformPointUnscaled(currentTransforms)(position); +} + +export function untransformNodePosition(position: Vector3, state: OxalisState): Vector3 { + const dataset = state.dataset; + const nativelyRenderedLayerName = state.datasetConfiguration.nativelyRenderedLayerName; + + const currentTransforms = getTransformsForSkeletonLayer(dataset, nativelyRenderedLayerName); + return transformPointUnscaled(invertTransform(currentTransforms))(position); +} + export function getMaxNodeIdInTree(tree: Tree): Maybe { const maxNodeId = _.reduce( Array.from(tree.nodes.keys()), diff --git a/frontend/javascripts/oxalis/model/accessors/tool_accessor.ts b/frontend/javascripts/oxalis/model/accessors/tool_accessor.ts index 7618f841da8..f108cde0173 100644 --- a/frontend/javascripts/oxalis/model/accessors/tool_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/tool_accessor.ts @@ -20,6 +20,7 @@ import { isFeatureAllowedByPricingPlan, PricingPlanEnum, } from "admin/organization/pricing_plan_utils"; +import { isSkeletonLayerTransformed } from "./skeletontracing_accessor"; const zoomInToUseToolMessage = "Please zoom in further to use this tool. If you want to edit volume data on this zoom level, create an annotation with restricted resolutions from the extended annotation menu in the dashboard."; @@ -80,25 +81,74 @@ export function isTraceTool(activeTool: AnnotationTool): boolean { const disabledSkeletonExplanation = "This annotation does not have a skeleton. Please convert it to a hybrid annotation."; +type DisabledInfo = { + isDisabled: boolean; + explanation: string; +}; + +const NOT_DISABLED_INFO = { + isDisabled: false, + explanation: "", +}; + +const ALWAYS_ENABLED_TOOL_INFOS = { + [AnnotationToolEnum.MOVE]: NOT_DISABLED_INFO, + [AnnotationToolEnum.LINE_MEASUREMENT]: NOT_DISABLED_INFO, + [AnnotationToolEnum.AREA_MEASUREMENT]: NOT_DISABLED_INFO, + [AnnotationToolEnum.BOUNDING_BOX]: NOT_DISABLED_INFO, +}; + +function _getSkeletonToolInfo(hasSkeleton: boolean, isSkeletonLayerTransformed: boolean) { + if (!hasSkeleton) { + return { + [AnnotationToolEnum.SKELETON]: { + isDisabled: true, + explanation: disabledSkeletonExplanation, + }, + }; + } + + if (isSkeletonLayerTransformed) { + return { + [AnnotationToolEnum.SKELETON]: { + isDisabled: true, + explanation: + "Skeleton annotation is disabled because the skeleton layer is transformed. Use the left sidebar to render the skeleton layer without any transformations.", + }, + }; + } + + return { + [AnnotationToolEnum.SKELETON]: NOT_DISABLED_INFO, + }; +} +const getSkeletonToolInfo = memoizeOne(_getSkeletonToolInfo); + function _getDisabledInfoWhenVolumeIsDisabled( - genericDisabledExplanation: string, - hasSkeleton: boolean, + isSegmentationTracingVisible: boolean, + isInMergerMode: boolean, + isSegmentationTracingVisibleForMag: boolean, + isZoomInvalidForTracing: boolean, + isEditableMappingActive: boolean, + isSegmentationTracingTransformed: boolean, isVolumeDisabled: boolean, + isJSONMappingActive: boolean, ) { + const genericDisabledExplanation = getExplanationForDisabledVolume( + isSegmentationTracingVisible, + isInMergerMode, + isSegmentationTracingVisibleForMag, + isZoomInvalidForTracing, + isEditableMappingActive, + isSegmentationTracingTransformed, + isJSONMappingActive, + ); + const disabledInfo = { isDisabled: true, explanation: genericDisabledExplanation, }; - const notDisabledInfo = { - isDisabled: false, - explanation: "", - }; return { - [AnnotationToolEnum.MOVE]: notDisabledInfo, - [AnnotationToolEnum.SKELETON]: { - isDisabled: !hasSkeleton, - explanation: disabledSkeletonExplanation, - }, [AnnotationToolEnum.BRUSH]: disabledInfo, [AnnotationToolEnum.ERASE_BRUSH]: disabledInfo, [AnnotationToolEnum.TRACE]: disabledInfo, @@ -106,13 +156,10 @@ function _getDisabledInfoWhenVolumeIsDisabled( [AnnotationToolEnum.FILL_CELL]: disabledInfo, [AnnotationToolEnum.QUICK_SELECT]: disabledInfo, [AnnotationToolEnum.PICK_CELL]: disabledInfo, - [AnnotationToolEnum.BOUNDING_BOX]: notDisabledInfo, [AnnotationToolEnum.PROOFREAD]: { isDisabled: isVolumeDisabled, explanation: genericDisabledExplanation, }, - [AnnotationToolEnum.LINE_MEASUREMENT]: notDisabledInfo, - [AnnotationToolEnum.AREA_MEASUREMENT]: notDisabledInfo, }; } @@ -124,7 +171,7 @@ function _getDisabledInfoForProofreadTool( activeOrganization: APIOrganization | null, activeUser: APIUser | null | undefined, ) { - // The explanations are prioritized according to effort the user has to put into + // The explanations are prioritized according to the 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. @@ -165,14 +212,13 @@ function _getDisabledInfoForProofreadTool( const getDisabledInfoWhenVolumeIsDisabled = memoizeOne(_getDisabledInfoWhenVolumeIsDisabled); const getDisabledInfoForProofreadTool = memoizeOne(_getDisabledInfoForProofreadTool); -function _getDisabledInfoFromArgs( +function _getVolumeDisabledWhenVolumeIsEnabled( hasSkeleton: boolean, isZoomStepTooHighForBrushing: boolean, isZoomStepTooHighForTracing: boolean, isZoomStepTooHighForFilling: boolean, isUneditableMappingLocked: boolean, agglomerateState: AgglomerateState, - genericDisabledExplanation: string, activeOrganization: APIOrganization | null, activeUser: APIUser | null | undefined, ) { @@ -182,14 +228,6 @@ function _getDisabledInfoFromArgs( ); return { - [AnnotationToolEnum.MOVE]: { - isDisabled: false, - explanation: "", - }, - [AnnotationToolEnum.SKELETON]: { - isDisabled: !hasSkeleton, - explanation: disabledSkeletonExplanation, - }, [AnnotationToolEnum.BRUSH]: { isDisabled: isZoomStepTooHighForBrushing, explanation: zoomInToUseToolMessage, @@ -210,14 +248,7 @@ function _getDisabledInfoFromArgs( isDisabled: isZoomStepTooHighForFilling, explanation: zoomInToUseToolMessage, }, - [AnnotationToolEnum.PICK_CELL]: { - isDisabled: false, - explanation: genericDisabledExplanation, - }, - [AnnotationToolEnum.BOUNDING_BOX]: { - isDisabled: false, - explanation: disabledSkeletonExplanation, - }, + [AnnotationToolEnum.PICK_CELL]: NOT_DISABLED_INFO, [AnnotationToolEnum.QUICK_SELECT]: { isDisabled: isZoomStepTooHighForFilling, explanation: zoomInToUseToolMessage, @@ -230,37 +261,22 @@ function _getDisabledInfoFromArgs( activeOrganization, activeUser, ), - [AnnotationToolEnum.LINE_MEASUREMENT]: { - isDisabled: false, - explanation: genericDisabledExplanation, - }, - [AnnotationToolEnum.AREA_MEASUREMENT]: { - isDisabled: false, - explanation: genericDisabledExplanation, - }, }; } -const getDisabledInfoFromArgs = memoizeOne(_getDisabledInfoFromArgs); -export function getDisabledInfoForTools(state: OxalisState): Record< - AnnotationTool, - { - isDisabled: boolean; - explanation: string; - } -> { +function getDisabledVolumeInfo(state: OxalisState) { + // This function extracts a couple of variables from the state + // so that it can delegate to memoized functions. 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; const segmentationTracingLayer = getActiveSegmentationTracing(state); - const maybeResolutionWithZoomStep = getRenderableResolutionForSegmentationTracing( + const labeledResolution = getRenderableResolutionForSegmentationTracing( state, segmentationTracingLayer, - ); - const labeledResolution = - maybeResolutionWithZoomStep != null ? maybeResolutionWithZoomStep.resolution : null; + )?.resolution; const isSegmentationTracingVisibleForMag = labeledResolution != null; const visibleSegmentationLayer = getVisibleSegmentationLayer(state); const isSegmentationTracingTransformed = @@ -274,22 +290,11 @@ 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 || @@ -301,28 +306,48 @@ export function getDisabledInfoForTools(state: OxalisState): Record< isJSONMappingActive || isSegmentationTracingTransformed; - if (isVolumeDisabled || isEditableMappingActive) { - // All segmentation-related tools are disabled. - return getDisabledInfoWhenVolumeIsDisabled( - genericDisabledExplanation, - hasSkeleton, - isVolumeDisabled, - ); - } + const isUneditableMappingLocked = + (segmentationTracingLayer?.mappingIsLocked && !segmentationTracingLayer?.mappingIsEditable) ?? + false; - const agglomerateState = hasAgglomerateMapping(state); - - return getDisabledInfoFromArgs( - hasSkeleton, - isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.BRUSH, state), - isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.TRACE, state), - isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.FILL_CELL, state), - isUneditableMappingLocked, - agglomerateState, - genericDisabledExplanation, - state.activeOrganization, - state.activeUser, - ); + return isVolumeDisabled || isEditableMappingActive + ? // All segmentation-related tools are disabled. + getDisabledInfoWhenVolumeIsDisabled( + isSegmentationTracingVisible, + isInMergerMode, + isSegmentationTracingVisibleForMag, + isZoomInvalidForTracing, + isEditableMappingActive, + isSegmentationTracingTransformed, + isVolumeDisabled, + isJSONMappingActive, + ) + : // Volume tools are not ALL disabled, but some of them might be. + getVolumeDisabledWhenVolumeIsEnabled( + hasSkeleton, + isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.BRUSH, state), + isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.TRACE, state), + isVolumeAnnotationDisallowedForZoom(AnnotationToolEnum.FILL_CELL, state), + isUneditableMappingLocked, + hasAgglomerateMapping(state), + state.activeOrganization, + state.activeUser, + ); +} + +const getVolumeDisabledWhenVolumeIsEnabled = memoizeOne(_getVolumeDisabledWhenVolumeIsEnabled); +export function getDisabledInfoForTools( + state: OxalisState, +): Record { + const hasSkeleton = state.tracing.skeleton != null; + const skeletonToolInfo = getSkeletonToolInfo(hasSkeleton, isSkeletonLayerTransformed(state)); + + const disabledVolumeInfo = getDisabledVolumeInfo(state); + return { + ...ALWAYS_ENABLED_TOOL_INFOS, + ...skeletonToolInfo, + ...disabledVolumeInfo, + }; } export function adaptActiveToolToShortcuts( diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx index 3d8d369a270..ae1c6b6d4d7 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx @@ -167,6 +167,10 @@ export const initializeSkeletonTracingAction = (tracing: ServerSkeletonTracing) }) as const; export const createNodeAction = ( + // Note that this position should not have any + // transformations applied. This is the value that + // will be stored in the back-end and on which potential + // transformations will be applied. position: Vector3, additionalCoordinates: AdditionalCoordinate[] | null, rotation: Vector3, diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index 73a491f3f43..d97d906c96a 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -25,11 +25,19 @@ import type { import { findGroup } from "oxalis/view/right-border-tabs/tree_hierarchy_view_helpers"; import messages from "messages"; import * as Utils from "libs/utils"; -import { BoundingBoxType, TreeType, TreeTypeEnum, Vector3 } from "oxalis/constants"; +import { + BoundingBoxType, + IdentityTransform, + TreeType, + TreeTypeEnum, + Vector3, +} from "oxalis/constants"; import Constants from "oxalis/constants"; import { location } from "libs/window"; import { coalesce } from "libs/utils"; import { type AdditionalCoordinate } from "types/api_flow_types"; +import { getNodePosition } from "../accessors/skeletontracing_accessor"; +import { getTransformsForSkeletonLayer } from "../accessors/dataset_accessor"; // NML Defaults const DEFAULT_COLOR: Vector3 = [1, 0, 0]; @@ -103,6 +111,10 @@ function serializeTag( .join(" ")}${closed ? " /" : ""}>`; } +function serializeXmlComment(comment: string) { + return ``; +} + export function getNmlName(state: OxalisState): string { // Use the same naming convention as the backend const { activeUser, dataset, task, tracing } = state; @@ -122,6 +134,7 @@ export function serializeToNml( annotation: Tracing, tracing: SkeletonTracing, buildInfo: APIBuildInfo, + applyTransform: boolean, ): string { // Only visible trees will be serialized! const visibleTrees = Utils.values(tracing.trees).filter((tree) => tree.isVisible); @@ -130,8 +143,8 @@ export function serializeToNml( ...indent( _.concat( serializeMetaInformation(state, annotation, buildInfo), - serializeParameters(state, annotation, tracing), - serializeTrees(visibleTrees), + serializeParameters(state, annotation, tracing, applyTransform), + serializeTrees(state, visibleTrees, applyTransform), serializeBranchPoints(visibleTrees), serializeComments(visibleTrees), "", @@ -223,6 +236,7 @@ function serializeParameters( state: OxalisState, annotation: Tracing, skeletonTracing: SkeletonTracing, + applyTransform: boolean, ): Array { const editPosition = getPosition(state.flycam).map(Math.round); const editPositionAdditionalCoordinates = state.flycam.additionalCoordinates; @@ -285,13 +299,66 @@ function serializeParameters( ), ) : []), + + ...(applyTransform ? serializeTransform(state) : []), ]), ), "", ]; } -function serializeTrees(trees: Array): Array { +function serializeTransform(state: OxalisState): string[] { + const transform = getTransformsForSkeletonLayer( + state.dataset, + state.datasetConfiguration.nativelyRenderedLayerName, + ); + + if (transform === IdentityTransform) { + return []; + } + + if (transform.type === "affine") { + return [ + serializeXmlComment( + "The node positions in this file were transformed using the following affine transform:", + ), + serializeTag("transform", { + type: "affine", + matrix: `[${transform.affineMatrix.join(",")}]`, + positionsAreTransformed: true, + }), + ]; + } else { + const correspondences = _.zip( + transform.scaledTps.unscaledSourcePoints, + transform.scaledTps.unscaledTargetPoints, + ) as Array<[Vector3, Vector3]>; + return [ + serializeXmlComment( + "The node positions in this file were transformed using a thin plate spline that was derived from the following correspondences:", + ), + ...serializeTagWithChildren( + "transform", + { + type: "thin_plate_spline", + positionsAreTransformed: true, + }, + correspondences.map((pair) => + serializeTag("correspondence", { + source: `[${pair[0].join(",")}]`, + target: `[${pair[1].join(",")}]`, + }), + ), + ), + ]; + } +} + +function serializeTrees( + state: OxalisState, + trees: Array, + applyTransform: boolean, +): Array { return _.flatten( trees.map((tree) => serializeTagWithChildren( @@ -305,7 +372,7 @@ function serializeTrees(trees: Array): Array { }, [ "", - ...indent(serializeNodes(tree.nodes)), + ...indent(serializeNodes(state, tree.nodes, applyTransform)), "", "", ...indent(serializeEdges(tree.edges)), @@ -316,9 +383,15 @@ function serializeTrees(trees: Array): Array { ); } -function serializeNodes(nodes: NodeMap): Array { +function serializeNodes( + state: OxalisState, + nodes: NodeMap, + applyTransform: boolean, +): Array { return nodes.map((node) => { - const position = node.position.map(Math.floor); + const position = ( + applyTransform ? getNodePosition(node, state) : node.untransformedPosition + ).map(Math.floor); const maybeProperties = additionalCoordinatesToObject(node.additionalCoordinates || []); return serializeTag("node", { @@ -755,7 +828,7 @@ export function parseNml(nmlString: string): Promise<{ const currentNode = { id: nodeId, - position: [ + untransformedPosition: [ Math.trunc(_parseFloat(attr, "x")), Math.trunc(_parseFloat(attr, "y")), Math.trunc(_parseFloat(attr, "z")), diff --git a/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.ts b/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.ts index e6a907094c7..90b4204917d 100644 --- a/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.ts +++ b/frontend/javascripts/oxalis/model/helpers/overwrite_action_middleware.ts @@ -1,11 +1,12 @@ import type { Dispatch, MiddlewareAPI } from "redux"; import type { Action } from "oxalis/model/actions/actions"; -const overwrites = {}; +type OverwriteFunction = (store: S, next: (action: A) => void, action: A) => A | Promise; +const overwrites: Record> = {}; + export function overwriteAction( actionName: string, - overwriteFunction: (store: S, next: (action: A) => void, action: A) => void | Promise, + overwriteFunction: OverwriteFunction, ) { - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message if (overwrites[actionName]) { console.warn( "There is already an overwrite for ", @@ -14,15 +15,12 @@ export function overwriteAction( ); } - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message overwrites[actionName] = overwriteFunction; return () => { - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message delete overwrites[actionName]; }; } export function removeOverwrite(actionName: string) { - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message delete overwrites[actionName]; } export default function overwriteMiddleware( @@ -32,7 +30,6 @@ export default function overwriteMiddleware( // @ts-expect-error ts-migrate(2322) FIXME: Type '(next: Dispatch) => (action: A) => A' is ... Remove this comment to see the full error message return (next: Dispatch) => (action: A): A => { - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message if (overwrites[action.type]) { let isSyncExecutionDone = false; @@ -54,7 +51,6 @@ export default function overwriteMiddleware( return next(...args); }; - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message const returnValue = overwrites[action.type](store, wrappedNext, action); isSyncExecutionDone = true; return returnValue; diff --git a/frontend/javascripts/oxalis/model/helpers/transformation_helpers.ts b/frontend/javascripts/oxalis/model/helpers/transformation_helpers.ts index e87345d816a..4f0ac826a27 100644 --- a/frontend/javascripts/oxalis/model/helpers/transformation_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/transformation_helpers.ts @@ -2,10 +2,20 @@ import { estimateAffineMatrix4x4 } from "libs/estimate_affine"; import { M4x4 } from "libs/mjs"; import TPS3D from "libs/thin_plate_spline"; import { Matrix4x4 } from "mjs"; -import { Vector3 } from "oxalis/constants"; +import { Vector3, Vector4 } from "oxalis/constants"; + +export function nestedToFlatMatrix(matrix: [Vector4, Vector4, Vector4, Vector4]): Matrix4x4 { + return [...matrix[0], ...matrix[1], ...matrix[2], ...matrix[3]]; +} export type Transform = - | { type: "affine"; affineMatrix: Matrix4x4 } + | { + type: "affine"; + affineMatrix: Matrix4x4; + // Store the inverse directly to avoid potential loss of quality + // due to later inversions + affineMatrixInv: Matrix4x4; + } | { type: "thin_plate_spline"; affineMatrix: Matrix4x4; @@ -16,12 +26,20 @@ export type Transform = scaledTps: TPS3D; }; +export function createAffineTransformFromMatrix( + nestedMatrix: [Vector4, Vector4, Vector4, Vector4], +): Transform { + const affineMatrix = nestedToFlatMatrix(nestedMatrix); + return { type: "affine", affineMatrix, affineMatrixInv: M4x4.inverse(affineMatrix) }; +} + export function createAffineTransform(source: Vector3[], target: Vector3[]): Transform { const affineMatrix = estimateAffineMatrix4x4(source, target); return { type: "affine", affineMatrix, + affineMatrixInv: M4x4.inverse(affineMatrix), }; } @@ -46,7 +64,8 @@ export function invertTransform(transforms: Transform): Transform { if (transforms.type === "affine") { return { type: "affine", - affineMatrix: M4x4.inverse(transforms.affineMatrix), + affineMatrix: transforms.affineMatrixInv, + affineMatrixInv: transforms.affineMatrix, }; } @@ -72,6 +91,7 @@ export function chainTransforms(transformsA: Transform | null, transformsB: Tran return { type: "affine", affineMatrix: M4x4.mul(transformsA.affineMatrix, transformsB.affineMatrix), + affineMatrixInv: M4x4.mul(transformsB.affineMatrixInv, transformsA.affineMatrixInv), }; } diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts index 6e702d5c2f3..24fd8bb5226 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts @@ -35,6 +35,7 @@ import { getTree, getTreesWithType, getNodeAndTree, + isSkeletonLayerTransformed, } from "oxalis/model/accessors/skeletontracing_accessor"; import ColorGenerator from "libs/color_generator"; import Constants, { AnnotationToolEnum, TreeTypeEnum } from "oxalis/constants"; @@ -575,6 +576,10 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState switch (action.type) { case "CREATE_NODE": { + if (isSkeletonLayerTransformed(state)) { + // Don't create nodes if the skeleton layer is rendered with transforms. + return state; + } const { position, rotation, @@ -704,6 +709,10 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState } case "SET_NODE_POSITION": { + if (isSkeletonLayerTransformed(state)) { + // Don't move node if the skeleton layer is rendered with transforms. + return state; + } const { position, nodeId, treeId } = action; return getNodeAndTree(skeletonTracing, nodeId, treeId, TreeTypeEnum.DEFAULT) .map(([tree, node]) => { @@ -711,7 +720,7 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState const newDiffableMap = diffableMap.set( node.id, update(node, { - position: { + untransformedPosition: { // Don't round here, since this would make the continuous // movement of a node weird. $set: position, diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts index cbdf66c57f2..83a56bb347f 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts @@ -139,7 +139,7 @@ export function createNode( const position = V3.trunc(positionFloat); // Create the new node const node: Node = { - position, + untransformedPosition: position, additionalCoordinates, radius, rotation, @@ -807,7 +807,7 @@ export function toggleTreeGroupReducer( function serverNodeToMutableNode(n: ServerNode): MutableNode { return { id: n.id, - position: Utils.point3ToVector3(n.position), + untransformedPosition: Utils.point3ToVector3(n.position), additionalCoordinates: n.additionalCoordinates, rotation: Utils.point3ToVector3(n.rotation), bitDepth: n.bitDepth, diff --git a/frontend/javascripts/oxalis/model/sagas/min_cut_saga.ts b/frontend/javascripts/oxalis/model/sagas/min_cut_saga.ts index 77ab0abf4ca..2cdb08bcf31 100644 --- a/frontend/javascripts/oxalis/model/sagas/min_cut_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/min_cut_saga.ts @@ -176,7 +176,10 @@ function removeOutgoingEdge(edgeBuffer: Uint16Array, idx: number, neighborIdx: n export function isBoundingBoxUsableForMinCut(boundingBoxObj: BoundingBoxType, nodes: Array) { const bbox = new BoundingBox(boundingBoxObj); - return bbox.containsPoint(nodes[0].position) && bbox.containsPoint(nodes[1].position); + return ( + bbox.containsPoint(nodes[0].untransformedPosition) && + bbox.containsPoint(nodes[1].untransformedPosition) + ); } type L = (x: number, y: number, z: number) => number; @@ -233,10 +236,18 @@ function* performMinCut(action: Action): Saga { boundingBoxObj = boundingBoxes[0].boundingBox; } else { const newBBox = { - min: V3.floor(V3.sub(V3.min(nodes[0].position, nodes[1].position), DEFAULT_PADDING)), + min: V3.floor( + V3.sub( + V3.min(nodes[0].untransformedPosition, nodes[1].untransformedPosition), + DEFAULT_PADDING, + ), + ), max: V3.floor( V3.add( - V3.add(V3.max(nodes[0].position, nodes[1].position), DEFAULT_PADDING), // Add [1, 1, 1], since BoundingBox.max is exclusive + V3.add( + V3.max(nodes[0].untransformedPosition, nodes[1].untransformedPosition), + DEFAULT_PADDING, + ), // Add [1, 1, 1], since BoundingBox.max is exclusive [1, 1, 1], ), ), @@ -244,9 +255,11 @@ function* performMinCut(action: Action): Saga { yield* put( addUserBoundingBoxAction({ boundingBox: newBBox, - name: `Bounding box used for splitting cell (seedA=(${nodes[0].position.join( + name: `Bounding box used for splitting cell (seedA=(${nodes[0].untransformedPosition.join( + ",", + )}), seedB=(${nodes[1].untransformedPosition.join( ",", - )}), seedB=(${nodes[1].position.join(",")}), timestamp=${new Date().getTime()})`, + )}), timestamp=${new Date().getTime()})`, color: Utils.getRandomColor(), isVisible: true, }), @@ -415,8 +428,8 @@ function* tryMinCutAtMag( ): Saga { const targetMagString = `${targetMag.join(",")}`; const boundingBoxTarget = boundingBoxMag1.fromMag1ToMag(targetMag); - const globalSeedA = V3.fromMag1ToMag(nodes[0].position, targetMag); - const globalSeedB = V3.fromMag1ToMag(nodes[1].position, targetMag); + const globalSeedA = V3.fromMag1ToMag(nodes[0].untransformedPosition, targetMag); + const globalSeedB = V3.fromMag1ToMag(nodes[1].untransformedPosition, targetMag); const minDistToSeed = Math.min(V3.length(V3.sub(globalSeedA, globalSeedB)) / 2, MIN_DIST_TO_SEED); console.log("Setting minDistToSeed to ", minDistToSeed); const seedA = V3.sub(globalSeedA, boundingBoxTarget.min); diff --git a/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts b/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts index 17eb00cd1be..0983497e7dc 100644 --- a/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/proofread_saga.ts @@ -20,6 +20,7 @@ import { findTreeByNodeId, getNodeAndTree, getTreeNameForAgglomerateSkeleton, + isSkeletonLayerTransformed, } from "oxalis/model/accessors/skeletontracing_accessor"; import { pushSaveQueueTransaction, @@ -296,8 +297,14 @@ function* handleSkeletonProofreadingAction(action: Action): Saga { const { agglomerateFileMag, getDataValue, volumeTracing } = preparation; const { tracingId: volumeTracingId } = volumeTracing; - const sourceNodePosition = sourceTree.nodes.get(sourceNodeId).position; - const targetNodePosition = targetTree.nodes.get(targetNodeId).position; + // Use untransformedPosition because agglomerate trees should not have + // any transforms, anyway. + if (yield* select((state) => isSkeletonLayerTransformed(state))) { + Toast.error("Proofreading is currently not supported when the skeleton layer is transformed."); + return; + } + const sourceNodePosition = sourceTree.nodes.get(sourceNodeId).untransformedPosition; + const targetNodePosition = targetTree.nodes.get(targetNodeId).untransformedPosition; const idInfos = yield* call(getAgglomerateInfos, preparation.getMappedAndUnmapped, [ sourceNodePosition, @@ -441,6 +448,13 @@ function* performMinCut( segmentsInfo, ); + // Use untransformedPosition below because agglomerate trees should not have + // any transforms, anyway. + if (yield* select((state) => isSkeletonLayerTransformed(state))) { + Toast.error("Proofreading is currently not supported when the skeleton layer is transformed."); + return true; + } + for (const edge of edgesToRemove) { if (sourceTree) { const result = getDeleteEdgeActionForEdgePositions(sourceTree, edge); @@ -888,9 +902,9 @@ function getDeleteEdgeActionForEdgePositions( let firstNodeId; let secondNodeId; for (const node of sourceTree.nodes.values()) { - if (_.isEqual(node.position, edge.position1)) { + if (_.isEqual(node.untransformedPosition, edge.position1)) { firstNodeId = node.id; - } else if (_.isEqual(node.position, edge.position2)) { + } else if (_.isEqual(node.untransformedPosition, edge.position2)) { secondNodeId = node.id; } if (firstNodeId && secondNodeId) { diff --git a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts index 8cb7c02f408..31eb956c599 100644 --- a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts @@ -49,6 +49,7 @@ import { findTreeByName, getTreeNameForAgglomerateSkeleton, getTreesWithType, + getNodePosition, } from "oxalis/model/accessors/skeletontracing_accessor"; import { getPosition, getRotation } from "oxalis/model/accessors/flycam_accessor"; import { @@ -101,19 +102,24 @@ function* centerActiveNode(action: Action): Saga { } } - getActiveNode(yield* select((state: OxalisState) => enforceSkeletonTracing(state.tracing))).map( - (activeNode) => { - if ("suppressAnimation" in action && action.suppressAnimation) { - Store.dispatch(setPositionAction(activeNode.position)); - Store.dispatch(setRotationAction(activeNode.rotation)); - } else { - api.tracing.centerPositionAnimated(activeNode.position, false, activeNode.rotation); - } - if (activeNode.additionalCoordinates) { - Store.dispatch(setAdditionalCoordinatesAction(activeNode.additionalCoordinates)); - } - }, + const activeNode = Utils.toNullable( + getActiveNode(yield* select((state: OxalisState) => enforceSkeletonTracing(state.tracing))), ); + + if (activeNode != null) { + const activeNodePosition = yield* select((state: OxalisState) => + getNodePosition(activeNode, state), + ); + if ("suppressAnimation" in action && action.suppressAnimation) { + Store.dispatch(setPositionAction(activeNodePosition)); + Store.dispatch(setRotationAction(activeNode.rotation)); + } else { + api.tracing.centerPositionAnimated(activeNodePosition, false, activeNode.rotation); + } + if (activeNode.additionalCoordinates) { + Store.dispatch(setAdditionalCoordinatesAction(activeNode.additionalCoordinates)); + } + } } function* watchBranchPointDeletion(): Saga { diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.ts b/frontend/javascripts/oxalis/model/sagas/update_actions.ts index 51fc2f118ba..2ef414cac5b 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.ts +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.ts @@ -216,20 +216,28 @@ export function deleteEdge(treeId: number, sourceNodeId: number, targetNodeId: n }, } as const; } + +export type UpdateActionNode = Omit & { + position: Node["untransformedPosition"]; + treeId: number; +}; + export function createNode(treeId: number, node: Node) { + const { untransformedPosition, ...restNode } = node; return { name: "createNode", - value: Object.assign({}, node, { - treeId, - }), + value: { ...restNode, position: untransformedPosition, treeId } as UpdateActionNode, } as const; } export function updateNode(treeId: number, node: Node) { + const { untransformedPosition, ...restNode } = node; return { name: "updateNode", - value: Object.assign({}, node, { + value: { + ...restNode, + position: untransformedPosition, treeId, - }), + } as UpdateActionNode, } as const; } export function deleteNode(treeId: number, nodeId: number) { diff --git a/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts b/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts index 61cd04b2b37..7c3164fdb76 100644 --- a/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts +++ b/frontend/javascripts/oxalis/shaders/main_data_shaders.glsl.ts @@ -452,7 +452,9 @@ void main() { _.each(layerNamesWithSegmentation, function(name) { if (tpsTransformPerLayer[name] != null) { %> - calculateTpsOffsetFor<%= name %>(worldCoordUVW, transWorldCoord); + tpsOffsetXYZ_<%= name %> = calculateTpsOffsetFor<%= name %>( + transDim(vec3(transWorldCoord.x, transWorldCoord.y, worldCoordUVW.z)) + ); <% } }) diff --git a/frontend/javascripts/oxalis/shaders/thin_plate_spline.glsl.ts b/frontend/javascripts/oxalis/shaders/thin_plate_spline.glsl.ts index 82e837f98ed..d8ecac88af3 100644 --- a/frontend/javascripts/oxalis/shaders/thin_plate_spline.glsl.ts +++ b/frontend/javascripts/oxalis/shaders/thin_plate_spline.glsl.ts @@ -49,25 +49,22 @@ ${aLines.join("\n")} } export function generateCalculateTpsOffsetFunction(name: string) { - return _.template( - ` - void calculateTpsOffsetFor<%= name %>(vec3 worldCoordUVW, vec3 transWorldCoord) { - vec3 originalWorldCoord = transDim(vec3(transWorldCoord.x, transWorldCoord.y, worldCoordUVW.z)); - + return ` + vec3 calculateTpsOffsetFor${name}(vec3 originalWorldCoord) { float x = originalWorldCoord.x * datasetScale.x; float y = originalWorldCoord.y * datasetScale.y; float z = originalWorldCoord.z * datasetScale.z; - vec3 a[4] = TPS_a_<%= name %>; + vec3 a[4] = TPS_a_${name}; vec3 linear_part = a[0] + x * a[1] + y * a[2] + z * a[3]; vec3 bending_part = vec3(0.0); - for (int cpIdx = 0; cpIdx < TPS_CPS_LENGTH_<%= name %>; cpIdx++) { + for (int cpIdx = 0; cpIdx < TPS_CPS_LENGTH_${name}; cpIdx++) { // Calculate distance to each control point float dist = sqrt( - pow(x - TPS_cps_<%= name %>[cpIdx].x, 2.0) + - pow(y - TPS_cps_<%= name %>[cpIdx].y, 2.0) + - pow(z - TPS_cps_<%= name %>[cpIdx].z, 2.0) + pow(x - TPS_cps_${name}[cpIdx].x, 2.0) + + pow(y - TPS_cps_${name}[cpIdx].y, 2.0) + + pow(z - TPS_cps_${name}[cpIdx].z, 2.0) ); if (dist != 0.0) { @@ -75,11 +72,11 @@ export function generateCalculateTpsOffsetFunction(name: string) { } else { dist = 0.; } - bending_part += dist * TPS_W_<%= name %>[cpIdx]; + bending_part += dist * TPS_W_${name}[cpIdx]; } - tpsOffsetXYZ_<%= name %> = (linear_part + bending_part) / datasetScale; + vec3 offset = (linear_part + bending_part) / datasetScale; + return offset; } - `, - )({ name }); + `; } diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index 11bd1264f98..448b6ffbe58 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -83,7 +83,7 @@ export type MutableEdge = { export type Edge = Readonly; export type MutableNode = { id: number; - position: Vector3; + untransformedPosition: Vector3; additionalCoordinates: AdditionalCoordinate[] | null; rotation: Vector3; bitDepth: number; @@ -318,6 +318,11 @@ export type DatasetConfiguration = { // that name (or id) should be rendered without any transforms. // This means, that all other layers should be transformed so that // they still correlated with each other. + // If nativelyRenderedLayerName is null, all layers are rendered + // as their transforms property signal it. + // Currently, the skeleton layer does not have transforms as a stored + // property. So, to render the skeleton layer natively, nativelyRenderedLayerName + // can be set to null. readonly nativelyRenderedLayerName: string | null; }; @@ -620,4 +625,6 @@ export function startSagas(rootSaga: Saga) { sagaMiddleware.run(rootSaga); } +export type StoreType = typeof store; + export default store; diff --git a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx index 85c660a46d3..595f49da2f3 100644 --- a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx @@ -949,7 +949,7 @@ export default function ToolbarView() { Store.dispatch( - cutAgglomerateFromNeighborsAction(clickedNode.position, clickedTree), + cutAgglomerateFromNeighborsAction( + clickedNode.untransformedPosition, + clickedTree, + ), ), label: ( setWaypoint(globalPosition, viewport, false), label: "Create Node here", + disabled: isSkeletonLayerTransformed(state), }, { key: "create-node-with-tree", @@ -1303,24 +1309,34 @@ function ContextMenuInner(propsWithInputRef: Props) { nodeContextMenuTree = nodeContextMenuTree as Tree | null; nodeContextMenuNode = nodeContextMenuNode as MutableNode | null; + const clickedNodesPosition = + nodeContextMenuNode != null ? getNodePosition(nodeContextMenuNode, Store.getState()) : null; + const positionToMeasureDistanceTo = - nodeContextMenuNode != null ? nodeContextMenuNode.position : globalPosition; + nodeContextMenuNode != null ? clickedNodesPosition : globalPosition; const activeNode = activeNodeId != null && skeletonTracing != null ? getNodeAndTree(skeletonTracing, activeNodeId, activeTreeId).get()[1] : null; + + const getActiveNodePosition = () => { + if (activeNode == null) { + throw new Error("getActiveNodePosition was called even though activeNode is null."); + } + return getNodePosition(activeNode, Store.getState()); + }; const distanceToSelection = activeNode != null && positionToMeasureDistanceTo != null ? [ formatNumberToLength( - V3.scaledDist(activeNode.position, positionToMeasureDistanceTo, datasetScale), + V3.scaledDist(getActiveNodePosition(), positionToMeasureDistanceTo, datasetScale), ), - formatLengthAsVx(V3.length(V3.sub(activeNode.position, positionToMeasureDistanceTo))), + formatLengthAsVx(V3.length(V3.sub(getActiveNodePosition(), positionToMeasureDistanceTo))), ] : null; const nodePositionAsString = - nodeContextMenuNode != null - ? positionToString(nodeContextMenuNode.position, nodeContextMenuNode.additionalCoordinates) + nodeContextMenuNode != null && clickedNodesPosition != null + ? positionToString(clickedNodesPosition, nodeContextMenuNode.additionalCoordinates) : ""; const infoRows = []; diff --git a/frontend/javascripts/oxalis/view/left-border-tabs/layer_settings_tab.tsx b/frontend/javascripts/oxalis/view/left-border-tabs/layer_settings_tab.tsx index f7fcd5aba33..c42fd7ddd3a 100644 --- a/frontend/javascripts/oxalis/view/left-border-tabs/layer_settings_tab.tsx +++ b/frontend/javascripts/oxalis/view/left-border-tabs/layer_settings_tab.tsx @@ -25,6 +25,7 @@ import { APIAnnotationTypeEnum, APIDataLayer, APIDataset, + APISkeletonLayer, APIJobType, EditableLayerProperties, } from "types/api_flow_types"; @@ -206,7 +207,7 @@ function DummyDragHandle({ layerType }: { layerType: string }) { ); } -function TransformationIcon({ layer }: { layer: APIDataLayer }) { +function TransformationIcon({ layer }: { layer: APIDataLayer | APISkeletonLayer }) { const dispatch = useDispatch(); const transform = useSelector((state: OxalisState) => getTransformsForLayerOrNull( @@ -233,7 +234,12 @@ function TransformationIcon({ layer }: { layer: APIDataLayer }) { const toggleLayerTransforms = () => { const state = Store.getState(); - if (state.datasetConfiguration.nativelyRenderedLayerName === layer.name) { + const { nativelyRenderedLayerName } = state.datasetConfiguration; + if ( + layer.category === "skeleton" + ? nativelyRenderedLayerName == null + : nativelyRenderedLayerName === layer.name + ) { return; } // Transform current position using the inverse transform @@ -257,7 +263,12 @@ function TransformationIcon({ layer }: { layer: APIDataLayer }) { // Only consider XY for now to determine the zoom change (by slicing from 0 to 2) V3.abs(V3.divide3(V3.sub(newPosition, newSecondPosition), referenceOffset)).slice(0, 2), ); - dispatch(updateDatasetSettingAction("nativelyRenderedLayerName", layer.name)); + dispatch( + updateDatasetSettingAction( + "nativelyRenderedLayerName", + layer.category === "skeleton" ? null : layer.name, + ), + ); dispatch(setPositionAction(newPosition)); dispatch(setZoomStepAction(state.flycam.zoomStep * scaleChange)); }; @@ -381,12 +392,14 @@ class DatasetSettings extends React.PureComponent { ); getDeleteAnnotationLayerButton = (readableName: string, layer?: APIDataLayer) => ( - - this.deleteAnnotationLayerIfConfirmed(readableName, layer)} - className="fas fa-trash icon-margin-right" - /> - +
+ + this.deleteAnnotationLayerIfConfirmed(readableName, layer)} + className="fas fa-trash icon-margin-right" + /> + +
); getDeleteAnnotationLayerDropdownOption = (readableName: string, layer?: APIDataLayer) => ( @@ -1162,7 +1175,15 @@ class DatasetSettings extends React.PureComponent { {readableName} - {!isOnlyAnnotationLayer ? this.getDeleteAnnotationLayerButton(readableName) : null} +
+ + {!isOnlyAnnotationLayer ? this.getDeleteAnnotationLayerButton(readableName) : null} +
{showSkeletons ? (
- - - 8 - - - Replace the color of the current active tree and its mapped segments with a new one. - - ({ - position: synapsePosition, + untransformedPosition: synapsePosition, radius: Constants.DEFAULT_NODE_RADIUS, rotation: [0, 0, 0], viewport: 0, diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx index c52472a062e..42c088335b3 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx @@ -41,6 +41,7 @@ import { getActiveTreeGroup, getTree, enforceSkeletonTracing, + isSkeletonLayerTransformed, } from "oxalis/model/accessors/skeletontracing_accessor"; import { getBuildInfo, importVolumeTracing, clearCache } from "admin/admin_rest_api"; import { @@ -84,12 +85,9 @@ import InputComponent from "oxalis/view/components/input_component"; import { Model } from "oxalis/singletons"; import type { OxalisState, - SkeletonTracing, - Tracing, Tree, TreeMap, TreeGroup, - UserConfiguration, MutableTreeMap, UserBoundingBox, } from "oxalis/store"; @@ -110,12 +108,7 @@ type TreeOrTreeGroup = { id: number; type: string; }; -type StateProps = { - annotation: Tracing; - skeletonTracing: SkeletonTracing | null | undefined; - userConfiguration: UserConfiguration; - allowUpdate: boolean; -}; +type StateProps = ReturnType; type DispatchProps = ReturnType; type Props = DispatchProps & StateProps; type State = { @@ -559,7 +552,7 @@ class SkeletonTabView extends React.PureComponent { Store.dispatch(toggleInactiveTreesAction()); } - handleNmlDownload = async () => { + handleNmlDownload = async (applyTransforms: boolean) => { const { skeletonTracing } = this.props; if (!skeletonTracing) { @@ -572,7 +565,13 @@ class SkeletonTabView extends React.PureComponent { // Wait 1 second for the Modal to render const [buildInfo] = await Promise.all([getBuildInfo(), Utils.sleep(1000)]); const state = Store.getState(); - const nml = serializeToNml(state, this.props.annotation, skeletonTracing, buildInfo); + const nml = serializeToNml( + state, + this.props.annotation, + skeletonTracing, + buildInfo, + applyTransforms, + ); this.setState({ isDownloading: false, }); @@ -737,11 +736,20 @@ class SkeletonTabView extends React.PureComponent { }, { key: "handleNmlDownload", - onClick: this.handleNmlDownload, - title: "Download visible trees as NML", + onClick: () => this.handleNmlDownload(false), icon: , label: "Download Visible Trees", + title: "Download Visible Trees as NML", }, + this.props.isSkeletonLayerTransformed + ? { + key: "handleNmlDownloadTransformed", + onClick: () => this.handleNmlDownload(true), + icon: , + label: "Download Visible Trees (Transformed)", + title: "The currently active transformation will be applied to each node.", + } + : null, { key: "importNml", onClick: this.props.showDropzoneModal, @@ -983,6 +991,7 @@ const mapStateToProps = (state: OxalisState) => ({ allowUpdate: state.tracing.restrictions.allowUpdate, skeletonTracing: state.tracing.skeleton, userConfiguration: state.userConfiguration, + isSkeletonLayerTransformed: isSkeletonLayerTransformed(state), }); const mapDispatchToProps = (dispatch: Dispatch) => ({ diff --git a/frontend/javascripts/test/geometries/skeleton.spec.ts b/frontend/javascripts/test/geometries/skeleton.spec.ts index 309eaf55bc0..52f63fb7ad5 100644 --- a/frontend/javascripts/test/geometries/skeleton.spec.ts +++ b/frontend/javascripts/test/geometries/skeleton.spec.ts @@ -85,7 +85,7 @@ test.serial("Skeleton should initialize correctly using the store's state", (t) ); for (const node of Array.from(tree.nodes.values())) { - nodePositions = nodePositions.concat(node.position); + nodePositions = nodePositions.concat(node.untransformedPosition); nodeTreeIds.push(tree.treeId); nodeRadii.push(node.radius); nodeIds.push(node.id); @@ -93,8 +93,8 @@ test.serial("Skeleton should initialize correctly using the store's state", (t) } for (const edge of tree.edges.all()) { - const sourcePosition = tree.nodes.get(edge.source).position; - const targetPosition = tree.nodes.get(edge.target).position; + const sourcePosition = tree.nodes.get(edge.source).untransformedPosition; + const targetPosition = tree.nodes.get(edge.target).untransformedPosition; edgePositions = edgePositions.concat(sourcePosition).concat(targetPosition); edgeTreeIds.push(tree.treeId, tree.treeId); } diff --git a/frontend/javascripts/test/libs/nml.spec.ts b/frontend/javascripts/test/libs/nml.spec.ts index 8528e9b952b..724114155c8 100644 --- a/frontend/javascripts/test/libs/nml.spec.ts +++ b/frontend/javascripts/test/libs/nml.spec.ts @@ -31,7 +31,7 @@ const SkeletonTracingActions: typeof OriginalSkeletonTracingActions = mock.reReq const createDummyNode = (id: number): Node => ({ bitDepth: 8, id, - position: [id, id, id], + untransformedPosition: [id, id, id], additionalCoordinates: [], radius: id, resolution: 10, @@ -199,6 +199,7 @@ async function testThatParserThrowsWithState( invalidState.tracing, enforceSkeletonTracing(invalidState.tracing), BUILD_INFO, + false, ); await throwsAsyncParseError(t, () => parseNml(nmlWithInvalidContent), key); } @@ -230,6 +231,7 @@ test("NML serializing and parsing should yield the same state", async (t) => { initialState.tracing, enforceSkeletonTracing(initialState.tracing), BUILD_INFO, + false, ); const { trees, treeGroups } = await parseNml(serializedNml); t.deepEqual(initialSkeletonTracing.trees, trees); @@ -259,6 +261,7 @@ test("NML serializing and parsing should yield the same state even when using sp state.tracing, enforceSkeletonTracing(state.tracing), BUILD_INFO, + false, ); const { trees, treeGroups } = await parseNml(serializedNml); const skeletonTracing = enforceSkeletonTracing(state.tracing); @@ -289,6 +292,7 @@ test("NML serializing and parsing should yield the same state even when using mu state.tracing, enforceSkeletonTracing(state.tracing), BUILD_INFO, + false, ); const { trees, treeGroups } = await parseNml(serializedNml); const skeletonTracing = enforceSkeletonTracing(state.tracing); @@ -323,6 +327,7 @@ test("NML serializing and parsing should yield the same state even when addition state.tracing, enforceSkeletonTracing(state.tracing), BUILD_INFO, + false, ); const { trees, treeGroups } = await parseNml(serializedNml); const skeletonTracing = enforceSkeletonTracing(state.tracing); @@ -348,6 +353,7 @@ test("NML Serializer should only serialize visible trees", async (t) => { state.tracing, enforceSkeletonTracing(state.tracing), BUILD_INFO, + false, ); const { trees } = await parseNml(serializedNml); const skeletonTracing = enforceSkeletonTracing(state.tracing); @@ -375,6 +381,7 @@ test("NML Serializer should only serialize groups with visible trees", async (t) state.tracing, enforceSkeletonTracing(state.tracing), BUILD_INFO, + false, ); const { treeGroups } = await parseNml(serializedNml); const skeletonTracing = enforceSkeletonTracing(state.tracing); @@ -388,6 +395,7 @@ test("NML serializer should produce correct NMLs", (t) => { initialState.tracing, enforceSkeletonTracing(initialState.tracing), BUILD_INFO, + false, ); t.snapshot(serializedNml, { id: "nml", @@ -432,6 +440,7 @@ test("NML serializer should produce correct NMLs with additional coordinates", ( adaptedState.tracing, enforceSkeletonTracing(adaptedState.tracing), BUILD_INFO, + false, ); t.snapshot(serializedNml, { id: "nml-with-additional-coordinates", @@ -464,6 +473,7 @@ test("NML serializer should escape special characters and multilines", (t) => { state.tracing, enforceSkeletonTracing(state.tracing), BUILD_INFO, + false, ); // Explicitly check for the encoded characters t.true( @@ -803,6 +813,7 @@ test("NML Parser should split up disconnected trees", async (t) => { disconnectedTreeState.tracing, enforceSkeletonTracing(disconnectedTreeState.tracing), BUILD_INFO, + false, ); const { trees: parsedTrees, treeGroups: parsedTreeGroups } = await parseNml(nmlWithDisconnectedTree); diff --git a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts index 3fa9607ee04..0403d79a9b0 100644 --- a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts @@ -126,7 +126,7 @@ test("SkeletonTracing should add a new node", (t) => { t.is(newSkeletonTracing.activeNodeId, 1); t.deepEqual(newSkeletonTracing.trees[1].edges.size(), 0); deepEqualObjectContaining(t, newSkeletonTracing.trees[1].nodes.get(1), { - position, + untransformedPosition: position, rotation, viewport, resolution, @@ -291,7 +291,7 @@ test("SkeletonTracing should delete nodes and split the tree", (t) => { const createDummyNode = (id: number): Node => ({ bitDepth: 8, id, - position: [0, 0, 0], + untransformedPosition: [0, 0, 0], additionalCoordinates: null, radius: 10, resolution: 10, @@ -447,7 +447,7 @@ test("SkeletonTracing should delete an edge and split the tree", (t) => { const createDummyNode = (id: number): Node => ({ bitDepth: 8, id, - position: [0, 0, 0], + untransformedPosition: [0, 0, 0], additionalCoordinates: null, radius: 10, resolution: 10, diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 138d8c03183..5603f1fa485 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -91,6 +91,10 @@ export type APISegmentationLayer = APIDataLayerBase & { }; export type APIDataLayer = APIColorLayer | APISegmentationLayer; +// Only used in rare cases to generalize over actual data layers and +// a skeleton layer. +export type APISkeletonLayer = { category: "skeleton" }; + export type LayerLink = { datasetId: APIDatasetId; sourceName: string;