Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
958d146
implement first draft of enhanced pipette tool
philippotto Oct 9, 2025
7623cc5
format
philippotto Oct 9, 2025
47c9055
fix imprecise position of tooltip
philippotto Oct 9, 2025
ecd45e7
support volume layer
philippotto Oct 9, 2025
78ea463
fix that mouse position to voxel mapping was slightly wrong when zoom…
philippotto Oct 9, 2025
9c94cb0
pass additionalCoordinates
philippotto Oct 9, 2025
0368512
support transformed layers
philippotto Oct 9, 2025
7c2f373
tmp: disable frontend tests in ci
philippotto Oct 10, 2025
a15000b
fix hovered segment id when layer is transformed
philippotto Oct 10, 2025
0117b86
dynamic offset
philippotto Oct 10, 2025
b64d4d7
also show in view mode
philippotto Oct 10, 2025
788787f
fix 3d viewport related crash
philippotto Oct 20, 2025
09bad02
refactor
philippotto Oct 23, 2025
eb98490
fix tooltip positioning
philippotto Oct 23, 2025
949940c
improve tooltip wording
philippotto Oct 23, 2025
f9506a8
also respect mappings in pipette tooltip
philippotto Oct 23, 2025
409784e
rename pick-cell tool to voxel-picker tool
philippotto Oct 23, 2025
bbc4ecb
update docs and enable old picking behavior with shift
philippotto Oct 23, 2025
d1e7a4c
fix one test
philippotto Oct 23, 2025
ae78342
wording
philippotto Oct 23, 2025
9ae429e
restore old round behavior
philippotto Oct 23, 2025
c7bc32e
add comment
philippotto Oct 23, 2025
63d012e
Merge branch 'master' of github.com:scalableminds/webknossos into vox…
philippotto Oct 23, 2025
7e37e28
update changelog
philippotto Oct 23, 2025
11706bc
restore CI
philippotto Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/ui/toolbar.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ And many other tools. For detailed information, see the [volume annotation guide
![Measurement Tool](./images/measure-tool.jpg){align=left width="60"}
**Measurement Tool**: Allows you to calculate distances between points of interest, measure surface areas of segmented structures, and place waypoints for complex measurements. See also [statistics for volume annotations](../volume_annotation/segments_statistics.md).

![Voxel Pipette Tool](../ui/images/segment-picker-tool.jpg){align=left width="60"}
**Voxel Pipette**: Hover over your data to see the exact values at a specific voxel. A tooltip will be shown that shows the intensity values for color layers and the segment id for the visible segmentation layer. The tooltip can be pinned by clicking so that the values can be copied. Pressing shift while clicking will activate the hovered segment id. This is alternative to selecting the segment ID from the [Segments list](./segments_list.md) sidebar or context menu.

![AI Analysis Tools](./images/ai-analysis-tools.jpg){align=left width="60"}
**AI Analysis**: Launch automated segmentation processes using various analysis workflows. The AI tools can significantly speed up your annotation work. Learn more about [AI-assisted analysis](../automation/ai_segmentation.md).

Expand All @@ -134,4 +137,4 @@ And many other tools. For detailed information, see the [volume annotation guide

To create a bounding box, select the tool and then click and drag with the mouse on one of the 2D viewports. You can resize the bounding box by dragging its corners or edges.

All created bounding boxes are listed in the **BBoxes** tab in the right-hand sidebar. From there, you can manage your bounding boxes, for example, by renaming, deleting, or jumping to them. Read more about the sidebar tabs in the [Object Info and Lists](./object_info.md) documentation.
All created bounding boxes are listed in the **BBoxes** tab in the right-hand sidebar. From there, you can manage your bounding boxes, for example, by renaming, deleting, or jumping to them. Read more about the sidebar tabs in the [Object Info and Lists](./object_info.md) documentation.
3 changes: 0 additions & 3 deletions docs/volume_annotation/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ Volume annotation in WEBKNOSSOS allows you to label and segment 3D structures in
- The fill behavior can be modified using the 2D/3D fill modifiers (see below).
- This tool only takes existing labels into account and does not look at any other underlying (microscopy) layers. Have a look at the [quick-select-tool](#quick-select-tool) for quickly annotating new structures.

![Segment Picker Tool](../ui/images/segment-picker-tool.jpg){align=left width="60"}
**Segment Picker**: Click any segment to use its label ID as the active segment ID and keep annotating with that ID. This is alternative to selecting the segment ID from the [Segments list](./segments_list.md) sidebar or context menu.

![Quick Select Tool](../ui/images/quickselect-tool.jpg){align=left width="60"}
**Quick Select**: Automatically annotate segments using either:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe("VolumeTracing", () => {
newState = UiReducer(newState, cycleToolAction());
expect(newState.uiInformation.activeTool).toBe(AnnotationTool.FILL_CELL);
newState = UiReducer(newState, cycleToolAction());
expect(newState.uiInformation.activeTool).toBe(AnnotationTool.PICK_CELL);
expect(newState.uiInformation.activeTool).toBe(AnnotationTool.VOXEL_PIPETTE);
newState = UiReducer(newState, cycleToolAction());
expect(newState.uiInformation.activeTool).toBe(AnnotationTool.QUICK_SELECT);
newState = UiReducer(newState, cycleToolAction());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AnnotationTool, VolumeTools } from "viewer/model/accessors/tool_accesso
import type { CoordinateTransformation } from "types/api_types";

const zoomSensitiveVolumeTools = VolumeTools.filter(
(name) => name !== AnnotationTool.PICK_CELL,
(name) => name !== AnnotationTool.VOXEL_PIPETTE,
) as AnnotationTool[];

const zoomedInInitialState = update(initialState, {
Expand Down Expand Up @@ -152,6 +152,7 @@ describe("Annotation Tool Disabled Info", () => {
AnnotationTool.MOVE,
AnnotationTool.LINE_MEASUREMENT,
AnnotationTool.AREA_MEASUREMENT,
AnnotationTool.VOXEL_PIPETTE,
] as AnnotationTool[];
const disabledInfo = getDisabledInfoForTools(rotatedState);
for (const tool of Object.values(AnnotationTool)) {
Expand Down
10 changes: 5 additions & 5 deletions frontend/javascripts/test/sagas/annotation_tool_saga.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
DrawToolController,
EraseToolController,
FillCellToolController,
PickCellToolController,
VoxelPipetteToolController,
QuickSelectToolController,
ProofreadToolController,
LineMeasurementToolController,
Expand All @@ -56,7 +56,7 @@ describe("Annotation Tool Saga", () => {
DrawToolController,
EraseToolController,
FillCellToolController,
PickCellToolController,
VoxelPipetteToolController,
QuickSelectToolController,
ProofreadToolController,
LineMeasurementToolController,
Expand Down Expand Up @@ -98,7 +98,7 @@ describe("Annotation Tool Saga", () => {
cycleTool();
expect(FillCellToolController.onToolDeselected).toHaveBeenCalledTimes(1);
cycleTool();
expect(PickCellToolController.onToolDeselected).toHaveBeenCalledTimes(1);
expect(VoxelPipetteToolController.onToolDeselected).toHaveBeenCalledTimes(1);
cycleTool();
expect(QuickSelectToolController.onToolDeselected).toHaveBeenCalledTimes(1);
cycleTool();
Expand Down Expand Up @@ -139,10 +139,10 @@ describe("Annotation Tool Saga", () => {
expect(DrawToolController.onToolDeselected).toHaveBeenCalledTimes(2);
cycleTool(AnnotationTool.FILL_CELL);
expect(EraseToolController.onToolDeselected).toHaveBeenCalledTimes(2);
cycleTool(AnnotationTool.PICK_CELL);
cycleTool(AnnotationTool.VOXEL_PIPETTE);
expect(FillCellToolController.onToolDeselected).toHaveBeenCalledTimes(1);
cycleTool(AnnotationTool.BOUNDING_BOX);
expect(PickCellToolController.onToolDeselected).toHaveBeenCalledTimes(1);
expect(VoxelPipetteToolController.onToolDeselected).toHaveBeenCalledTimes(1);
cycleTool(AnnotationTool.PROOFREAD);
expect(BoundingBoxToolController.onToolDeselected).toHaveBeenCalledTimes(1);
cycleTool(AnnotationTool.LINE_MEASUREMENT);
Expand Down
42 changes: 32 additions & 10 deletions frontend/javascripts/viewer/api/api_latest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
getLayerByName,
getMagInfo,
getMappingInfo,
getMappingInfoOrNull,
getVisibleSegmentationLayer,
} from "viewer/model/accessors/dataset_accessor";
import { flatToNestedMatrix } from "viewer/model/accessors/dataset_layer_transformation_accessor";
Expand Down Expand Up @@ -1516,15 +1517,15 @@ class TracingApi {

/**
* Returns the active tool which is either
* "MOVE", "SKELETON", "TRACE", "BRUSH", "FILL_CELL" or "PICK_CELL"
* "MOVE", "SKELETON", "TRACE", "BRUSH", "FILL_CELL" or "VOXEL_PIPETTE"
*/
getAnnotationTool(): AnnotationToolId {
return Store.getState().uiInformation.activeTool.id;
}

/**
* Sets the active tool which should be either
* "MOVE", "SKELETON", "TRACE", "BRUSH", "FILL_CELL" or "PICK_CELL"
* "MOVE", "SKELETON", "TRACE", "BRUSH", "FILL_CELL" or "VOXEL_PIPETTE"
* _Volume tracing only!_
*/
setAnnotationTool(toolId: AnnotationToolId) {
Expand Down Expand Up @@ -1863,42 +1864,63 @@ class DataApi {
*/
async getDataValue(
layerName: string,
position: Vector3,
position: Vector3, // in layer space
_zoomStep: number | null | undefined = null,
additionalCoordinates: AdditionalCoordinate[] | null = null,
respectMapping: boolean = false,
): Promise<number> {
let zoomStep;
const state = Store.getState();

if (_zoomStep != null) {
zoomStep = _zoomStep;
} else {
const layer = getLayerByName(Store.getState().dataset, layerName);
const layer = getLayerByName(state.dataset, layerName);
const magInfo = getMagInfo(layer.mags);
zoomStep = magInfo.getFinestMagIndex();
}

const cube = this.model.getCubeByLayerName(layerName);
additionalCoordinates = additionalCoordinates || Store.getState().flycam.additionalCoordinates;
additionalCoordinates = additionalCoordinates || state.flycam.additionalCoordinates;
const bucketAddress = cube.positionToZoomedAddress(position, additionalCoordinates, zoomStep);
await this.getLoadedBucket(layerName, bucketAddress);

let mapping = null;
if (respectMapping) {
const activeMappingInfo = getMappingInfoOrNull(
state.temporaryConfiguration.activeMappingByLayer,
layerName,
);
mapping =
activeMappingInfo?.mappingStatus === MappingStatusEnum.ENABLED
? activeMappingInfo.mapping
: null;
}

// Bucket has been loaded by now or was loaded already
const dataValue = cube.getDataValue(position, additionalCoordinates, null, zoomStep);
const dataValue = cube.getDataValue(position, additionalCoordinates, mapping, zoomStep);
return dataValue;
}

/**
* Returns the magnification that is _currently_ rendered at the given position.
*/
getRenderedZoomStepAtPosition(layerName: string, position: Vector3 | null | undefined): number {
return this.model.getCurrentlyRenderedZoomStepAtPosition(layerName, position);
getRenderedZoomStepAtPosition(
layerName: string,
positionInLayerSpace: Vector3 | null | undefined,
): number {
return this.model.getCurrentlyRenderedZoomStepAtPosition(layerName, positionInLayerSpace);
}

/**
* Returns the maginfication that will _ultimately_ be rendered at the given position, once
* all respective buckets are loaded.
*/
getUltimatelyRenderedZoomStepAtPosition(layerName: string, position: Vector3): Promise<number> {
return this.model.getUltimatelyRenderedZoomStepAtPosition(layerName, position);
getUltimatelyRenderedZoomStepAtPosition(
layerName: string,
positionInLayerSpace: Vector3,
): Promise<number> {
return this.model.getUltimatelyRenderedZoomStepAtPosition(layerName, positionInLayerSpace);
}

async getLoadedBucket(layerName: string, bucketAddress: BucketAddress): Promise<Bucket> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export class MoveToolController {
_ctrlOrMetaKey: boolean,
altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
const { useLegacyBindings } = userConfiguration;
// In legacy mode, don't display a hint for
Expand Down Expand Up @@ -377,6 +378,7 @@ export class SkeletonToolController {
ctrlOrMetaKey: boolean,
altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
const { continuousNodeCreation } = Store.getState().userConfiguration;
const { useLegacyBindings } = userConfiguration;
Expand Down Expand Up @@ -514,6 +516,7 @@ export class DrawToolController {
_ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
let rightClick;
const { useLegacyBindings } = userConfiguration;
Expand Down Expand Up @@ -574,6 +577,7 @@ export class EraseToolController {
_ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
return {
leftDrag: `Erase (${activeTool === AnnotationTool.ERASE_BRUSH ? "Brush" : "Trace"})`,
Expand All @@ -583,30 +587,59 @@ export class EraseToolController {

static onToolDeselected() {}
}
export class PickCellToolController {
export class VoxelPipetteToolController {
static getPlaneMouseControls(_planeId: OrthoView): any {
return {
leftClick: (pos: Point2, _plane: OrthoView, _event: MouseEvent) => {
VolumeHandlers.handlePickCell(pos);
mouseMove: (
_delta: Point2,
position: Point2,
plane: OrthoView | null | undefined,
event: MouseEvent,
) => {
MoveHandlers.moveWhenAltIsPressed(_delta, position, plane, event);
},
leftClick: (position: Point2, plane: OrthoView, event: MouseEvent) => {
if (event.shiftKey) {
VolumeHandlers.handlePickCell(position);
return;
}

const state = Store.getState();
const lastMeasuredGlobalPosition =
state.uiInformation.measurementToolInfo.lastMeasuredPosition;

if (lastMeasuredGlobalPosition == null) {
const globalPosition = calculateGlobalPos(state, position, plane).floating;
Store.dispatch(setLastMeasuredPositionAction(globalPosition));
} else {
Store.dispatch(hideMeasurementTooltipAction());
}
Store.dispatch(setIsMeasuringAction(true));
Comment on lines +602 to +617
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Left click toggle leaves measuring=true after hiding.

When unpinning (hide), set measuring=false; otherwise the UI stays in “measuring” state.

       if (lastMeasuredGlobalPosition == null) {
         const globalPosition = calculateGlobalPos(state, position, plane).floating;
         Store.dispatch(setLastMeasuredPositionAction(globalPosition));
       } else {
         Store.dispatch(hideMeasurementTooltipAction());
+        Store.dispatch(setIsMeasuringAction(false));
+        return;
       }
-      Store.dispatch(setIsMeasuringAction(true));
+      Store.dispatch(setIsMeasuringAction(true));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (event.shiftKey) {
VolumeHandlers.handlePickCell(position);
return;
}
const state = Store.getState();
const lastMeasuredGlobalPosition =
state.uiInformation.measurementToolInfo.lastMeasuredPosition;
if (lastMeasuredGlobalPosition == null) {
const globalPosition = calculateGlobalPos(state, position, plane).floating;
Store.dispatch(setLastMeasuredPositionAction(globalPosition));
} else {
Store.dispatch(hideMeasurementTooltipAction());
}
Store.dispatch(setIsMeasuringAction(true));
if (lastMeasuredGlobalPosition == null) {
const globalPosition = calculateGlobalPos(state, position, plane).floating;
Store.dispatch(setLastMeasuredPositionAction(globalPosition));
} else {
Store.dispatch(hideMeasurementTooltipAction());
Store.dispatch(setIsMeasuringAction(false));
return;
}
Store.dispatch(setIsMeasuringAction(true));
🤖 Prompt for AI Agents
In frontend/javascripts/viewer/controller/combinations/tool_controls.ts around
lines 602 to 617, the left-click branch that hides (unpins) the measurement
tooltip does not clear the measuring flag so the UI remains in measuring=true;
when lastMeasuredGlobalPosition is null you should continue to set
measuring=true after storing the computed globalPosition, but in the else branch
where you dispatch hideMeasurementTooltipAction() also dispatch
setIsMeasuringAction(false) to turn off measuring; implement that single
additional dispatch in the hide branch so measuring is cleared when unpinning.

},
};
}

static onToolDeselected() {
Store.dispatch(hideMeasurementTooltipAction());
}

static getActionDescriptors(
_activeTool: AnnotationTool,
_userConfiguration: UserConfiguration,
_shiftKey: boolean,
shiftKey: boolean,
_ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
return {
leftClick: "Pick Segment",
leftClick:
_activeToolWithoutModifiers === AnnotationTool.VOXEL_PIPETTE && !shiftKey
? "Pin Tooltip"
: "Activate Segment ID",
rightClick: "Context Menu",
};
}

static onToolDeselected() {}
}
export class FillCellToolController {
static getPlaneMouseControls(_planeId: OrthoView): any {
Expand All @@ -630,6 +663,7 @@ export class FillCellToolController {
_ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
return {
leftClick: "Fill Segment",
Expand Down Expand Up @@ -709,6 +743,7 @@ export class BoundingBoxToolController {
ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
return {
leftDrag: ctrlOrMetaKey ? "Move Bounding Boxes" : "Create/Resize Bounding Boxes",
Expand Down Expand Up @@ -838,6 +873,7 @@ export class QuickSelectToolController {
_ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
return {
leftDrag: shiftKey ? "Resize Rectangle symmetrically" : "Draw Rectangle around Segment",
Expand Down Expand Up @@ -962,6 +998,7 @@ export class LineMeasurementToolController {
_ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
return {
leftClick: "Left Click to measure distance",
Expand Down Expand Up @@ -1041,6 +1078,7 @@ export class AreaMeasurementToolController {
_ctrlOrMetaKey: boolean,
_altKey: boolean,
_isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
return {
leftDrag: "Drag to measure area",
Expand Down Expand Up @@ -1114,6 +1152,7 @@ export class ProofreadToolController {
ctrlOrMetaKey: boolean,
_altKey: boolean,
isTDViewportActive: boolean,
_activeToolWithoutModifiers: AnnotationTool,
): ActionDescriptor {
const { isMultiSplitActive } = userConfiguration;

Expand Down Expand Up @@ -1172,7 +1211,7 @@ const toolToToolController = {
[AnnotationTool.ERASE_TRACE.id]: EraseToolController,
[AnnotationTool.ERASE_BRUSH.id]: EraseToolController,
[AnnotationTool.FILL_CELL.id]: FillCellToolController,
[AnnotationTool.PICK_CELL.id]: PickCellToolController,
[AnnotationTool.VOXEL_PIPETTE.id]: VoxelPipetteToolController,
[AnnotationTool.LINE_MEASUREMENT.id]: LineMeasurementToolController,
[AnnotationTool.AREA_MEASUREMENT.id]: AreaMeasurementToolController,
};
Expand Down
Loading