diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 3143b194c12..5a82f4a9227 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -33,6 +33,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Fixed that the minimum size of bounding boxes for AI neuron and mitochondria inferral was not checked before starting the job. [#8561](https://github.com/scalableminds/webknossos/pull/8561) - Fixed that layer bounding boxes were sometimes colored green even though this should only happen for tasks. [#8535](https://github.com/scalableminds/webknossos/pull/8535) - Fixed that annotations could not be opened anymore (caused by #8535). [#8599](https://github.com/scalableminds/webknossos/pull/8599) +- Voxels outside of the layer bounding box cannot be brushed, anymore. [#8602](https://github.com/scalableminds/webknossos/pull/8602) - The guest tag is now also shown for guest admin users. [#8612](https://github.com/scalableminds/webknossos/pull/8612) - Fixed a rare bug where segment bounding box would not be displayed correctly, with the request potentially even crashing the server. [#8590](https://github.com/scalableminds/webknossos/pull/8590) - Fixed a rare bug where download requests would terminate without sending the whole annotation. [#8624](https://github.com/scalableminds/webknossos/pull/8624) diff --git a/docs/volume_annotation/segments_list.md b/docs/volume_annotation/segments_list.md index 85d1e7f10a7..83780eda102 100644 --- a/docs/volume_annotation/segments_list.md +++ b/docs/volume_annotation/segments_list.md @@ -6,6 +6,7 @@ The following functionality is available for each segment: - jumping to the segment (via left-click; this uses the position at which the segment was initially registered) - naming the segment +- toggling the visibility of the segment (the visibility of unlisted segments is controlled with the `Hide unlisted segments` setting in the left sidebar in the `Layers` tab) - [loading 3D meshes](../meshes/loading_meshes.md) for the segments (ad-hoc and precomputed if available) - download of 3D meshes - changing the color of the segment, and if its mesh is visible, changing the mesh's opacity using the opacity slider of the segment color picker @@ -15,4 +16,4 @@ The following functionality is available for each segment: ![youtube-video](https://www.youtube.com/embed/BJ7lblTSVKY) Working with groups allows you to perform batch actions of a whole group of segment (e.g. changing the color, loading meshes, ...) -![youtube-video](https://www.youtube.com/embed/lz-3kFWQ2H8) \ No newline at end of file +![youtube-video](https://www.youtube.com/embed/lz-3kFWQ2H8) diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 05bffa12a82..8de352dfd43 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -16,6 +16,7 @@ import type { import type { BoundingBoxObject, NumberLike } from "viewer/store"; type UrlParams = Record; + // Fix JS modulo bug // http://javascript.about.com/od/problemsolving/a/modulobug.htm export function mod(x: number, n: number) { diff --git a/frontend/javascripts/test/helpers/apiHelpers.ts b/frontend/javascripts/test/helpers/apiHelpers.ts index 570d0d76a09..1cba1ded9c0 100644 --- a/frontend/javascripts/test/helpers/apiHelpers.ts +++ b/frontend/javascripts/test/helpers/apiHelpers.ts @@ -190,8 +190,7 @@ export async function setupWebknossosForTesting( testContext.setSlowCompression = setSlowCompression; testContext.tearDownPullQueues = () => Model.getAllLayers().map((layer) => { - layer.pullQueue.clear(); - layer.pullQueue.abortRequests(); + layer.pullQueue.destroy(); }); const webknossos = new WebknossosApi(Model); diff --git a/frontend/javascripts/test/model/binary/cube.spec.ts b/frontend/javascripts/test/model/binary/cube.spec.ts index 04bb7a76740..1186f9c1035 100644 --- a/frontend/javascripts/test/model/binary/cube.spec.ts +++ b/frontend/javascripts/test/model/binary/cube.spec.ts @@ -86,7 +86,7 @@ describe("DataCube", () => { await sleep(10); for (const item of this.queue) { - const bucket = cube.getBucket(item.bucket, true); + const bucket = cube.getBucket(item.bucket); if (bucket.type === "data") { bucket.markAsRequested(); @@ -269,3 +269,46 @@ describe("DataCube", () => { expect(index).toBe(10570); }); }); + +// This is not executed in the tests, but can be activated when needed +// to make performance measurements for getOrCreateBucket +describe.skip("DataCube Benchmark", () => { + it("Benchmark", () => { + const mockedLayer = { + resolutions: [[1, 1, 1]] as Vector3[], + }; + const magInfo = new MagInfo(mockedLayer.resolutions); + const cube = new DataCube( + new BoundingBox({ min: [1024, 1024, 1024], max: [2048, 2048, 2048] }), + [], + magInfo, + "uint32", + false, + "layerName", + ); + + console.time("outside"); + for (let i = 0; i < 15; i++) { + for (let x = 0; x < 32; x++) { + for (let y = 0; y < 32; y++) { + for (let z = 0; z < 32; z++) { + cube.getOrCreateBucket([x, y, z, 0]); + } + } + } + } + console.timeEnd("outside"); + + console.time("inside"); + for (let i = 0; i < 15; i++) { + for (let x = 32; x < 64; x++) { + for (let y = 32; y < 64; y++) { + for (let z = 32; z < 64; z++) { + cube.getOrCreateBucket([x, y, z, 0]); + } + } + } + } + console.timeEnd("inside"); + }); +}); diff --git a/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts b/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts index cda7139af77..f2f84597055 100644 --- a/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts +++ b/frontend/javascripts/test/model/binary/layers/wkstore_adapter.spec.ts @@ -250,12 +250,24 @@ describe("wkstore_adapter", () => { it("sendToStore: Request Handling should send the correct request parameters", () => { const data = new Uint8Array(2); - const bucket1 = new DataBucket("uint8", [0, 0, 0, 0], null as any, mockedCube); + const bucket1 = new DataBucket( + "uint8", + [0, 0, 0, 0], + null as any, + { type: "full" }, + mockedCube, + ); bucket1.markAsRequested(); bucket1.receiveData(data); - const bucket2 = new DataBucket("uint8", [1, 1, 1, 1], null as any, mockedCube); + const bucket2 = new DataBucket( + "uint8", + [1, 1, 1, 1], + null as any, + { type: "full" }, + mockedCube, + ); bucket2.markAsRequested(); bucket2.receiveData(data); diff --git a/frontend/javascripts/test/model/binary/pullqueue.spec.ts b/frontend/javascripts/test/model/binary/pullqueue.spec.ts index 17e9937a2f8..bd154d4cf6f 100644 --- a/frontend/javascripts/test/model/binary/pullqueue.spec.ts +++ b/frontend/javascripts/test/model/binary/pullqueue.spec.ts @@ -83,8 +83,8 @@ describe("PullQueue", () => { const pullQueue = new PullQueue(mockedCube as any, "layername", datastoreInfo as any); mockedCube.pullQueue = pullQueue; const buckets = [ - new DataBucket("uint8", [0, 0, 0, 0], null as any, mockedCube as any), - new DataBucket("uint8", [1, 1, 1, 1], null as any, mockedCube as any), + new DataBucket("uint8", [0, 0, 0, 0], null as any, { type: "full" }, mockedCube as any), + new DataBucket("uint8", [1, 1, 1, 1], null as any, { type: "full" }, mockedCube as any), ]; mockedCube.getBucket.mockImplementation((address: BucketAddress) => { diff --git a/frontend/javascripts/test/model/binary/temporal_bucket_manager.spec.ts b/frontend/javascripts/test/model/binary/temporal_bucket_manager.spec.ts index 690a1e45d02..56b59c8a719 100644 --- a/frontend/javascripts/test/model/binary/temporal_bucket_manager.spec.ts +++ b/frontend/javascripts/test/model/binary/temporal_bucket_manager.spec.ts @@ -53,14 +53,14 @@ describe("TemporalBucketManager", () => { } it("should be added when bucket has not been requested", ({ manager, cube }) => { - const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, cube); + const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, { type: "full" }, cube); fakeLabel(bucket); expect(manager.getCount()).toBe(1); }); it("should be added when bucket has not been received", ({ manager, cube }) => { - const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, cube); + const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, { type: "full" }, cube); bucket.markAsRequested(); expect(bucket.needsRequest()).toBe(false); @@ -70,7 +70,7 @@ describe("TemporalBucketManager", () => { }); it("should not be added when bucket has been received", ({ manager, cube }) => { - const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, cube); + const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, { type: "full" }, cube); bucket.markAsRequested(); bucket.receiveData(new Uint8Array(1 << 15)); @@ -81,7 +81,7 @@ describe("TemporalBucketManager", () => { }); it("should be removed once it is loaded", ({ manager, cube }) => { - const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, cube); + const bucket = new DataBucket("uint8", [0, 0, 0, 0], manager, { type: "full" }, cube); fakeLabel(bucket); bucket.markAsRequested(); @@ -93,8 +93,8 @@ describe("TemporalBucketManager", () => { // Helper function to prepare buckets function prepareBuckets(manager: TemporalBucketManager, cube: any) { // Insert two buckets into manager - const bucket1 = new DataBucket("uint8", [0, 0, 0, 0], manager, cube); - const bucket2 = new DataBucket("uint8", [1, 0, 0, 0], manager, cube); + const bucket1 = new DataBucket("uint8", [0, 0, 0, 0], manager, { type: "full" }, cube); + const bucket2 = new DataBucket("uint8", [1, 0, 0, 0], manager, { type: "full" }, cube); for (const bucket of [bucket1, bucket2]) { bucket.startDataMutation(); diff --git a/frontend/javascripts/test/model/texture_bucket_manager.spec.ts b/frontend/javascripts/test/model/texture_bucket_manager.spec.ts index 65c9b622b9e..4a91f49c0d5 100644 --- a/frontend/javascripts/test/model/texture_bucket_manager.spec.ts +++ b/frontend/javascripts/test/model/texture_bucket_manager.spec.ts @@ -35,6 +35,7 @@ const buildBucket = (zoomedAddress: Vector4, firstByte: number) => { "uint8", zoomedAddress, temporalBucketManagerMock as any, + { type: "full" }, mockedCube as any, ); bucket._fallbackBucket = NULL_BUCKET; diff --git a/frontend/javascripts/test/model/volumetracing/volume_annotation_sampling.spec.ts b/frontend/javascripts/test/model/volumetracing/volume_annotation_sampling.spec.ts index 2fba8861f69..10454a962cc 100644 --- a/frontend/javascripts/test/model/volumetracing/volume_annotation_sampling.spec.ts +++ b/frontend/javascripts/test/model/volumetracing/volume_annotation_sampling.spec.ts @@ -1,6 +1,6 @@ import { tracing as skeletontracingServerObject } from "test/fixtures/skeletontracing_server_objects"; import { tracing as volumetracingServerObject } from "test/fixtures/volumetracing_server_objects"; -import type { Vector3, Vector4 } from "viewer/constants"; +import type { LabeledVoxelsMap, Vector3, Vector4 } from "viewer/constants"; import Constants from "viewer/constants"; import { describe, it, beforeEach, vi, expect } from "vitest"; import datasetServerObject from "test/fixtures/dataset_server_object"; @@ -8,6 +8,10 @@ import { MagInfo } from "viewer/model/helpers/mag_info"; import BoundingBox from "viewer/model/bucket_data_handling/bounding_box"; import type DataCubeType from "viewer/model/bucket_data_handling/data_cube"; import { assertNonNullBucket } from "viewer/model/bucket_data_handling/bucket"; +import DataCube from "viewer/model/bucket_data_handling/data_cube"; +import sampleVoxelMapToMag, { + applyVoxelMap, +} from "viewer/model/volumetracing/volume_annotation_sampling"; // Mock modules vi.mock("viewer/store", () => { @@ -39,12 +43,6 @@ vi.mock("viewer/model/sagas/root_saga", () => { type LabeledVoxelsMapAsArray = Array<[Vector4, Uint8Array]>; -// Import the modules after mocking -import DataCube from "viewer/model/bucket_data_handling/data_cube"; -import sampleVoxelMapToMag, { - applyVoxelMap, -} from "viewer/model/volumetracing/volume_annotation_sampling"; - // Test context type type TestContext = { cube: DataCubeType; @@ -66,6 +64,8 @@ function getVoxelMapEntry(firstDim: number, secondDim: number, voxelMap: Uint8Ar describe("Volume Annotation Sampling", () => { let context: TestContext; + const cubeBoundingBox = new BoundingBox({ min: [1, 2, 3], max: [1023, 1024, 1025] }); + beforeEach(() => { const mockedLayer = { resolutions: [ @@ -78,14 +78,7 @@ describe("Volume Annotation Sampling", () => { ] as Vector3[], }; const magInfo = new MagInfo(mockedLayer.resolutions); - const cube = new DataCube( - new BoundingBox({ min: [0, 0, 0], max: [1024, 1024, 1024] }), - [], - magInfo, - "uint32", - false, - "layerName", - ); + const cube = new DataCube(cubeBoundingBox, [], magInfo, "uint32", false, "layerName"); const pullQueue = { add: vi.fn(), pull: vi.fn(), @@ -94,7 +87,7 @@ describe("Volume Annotation Sampling", () => { insert: vi.fn(), push: vi.fn(), }; - // @ts-expect-error + // @ts-ignore cube.initializeWithQueues(pullQueue, pushQueue); context = { cube, @@ -603,7 +596,7 @@ describe("Volume Annotation Sampling", () => { const { cube } = context; const bucket = cube.getOrCreateBucket([0, 0, 0, 0]); assertNonNullBucket(bucket); - const labeledVoxelsMap = new Map(); + const labeledVoxelsMap: LabeledVoxelsMap = new Map(); const voxelMap = getEmptyVoxelMap(); const voxelsToLabel = [ [10, 10], @@ -615,6 +608,51 @@ describe("Volume Annotation Sampling", () => { [11, 12], [11, 13], ]; + const Z = 5; + voxelsToLabel.forEach(([firstDim, secondDim]) => + labelVoxelInVoxelMap(firstDim, secondDim, voxelMap), + ); + labeledVoxelsMap.set(bucket.zoomedAddress, voxelMap); + + const get3DAddress = (x: number, y: number, out: Vector3 | Float32Array) => { + out[0] = x; + out[1] = y; + out[2] = Z; + }; + + const expectedBucketData = new Uint32Array(Constants.BUCKET_SIZE).fill(0); + voxelsToLabel.forEach(([firstDim, secondDim]) => { + const addr = cube.getVoxelIndex([firstDim, secondDim, Z], 0); + expectedBucketData[addr] = 1; + }); + applyVoxelMap(labeledVoxelsMap, cube, 1, get3DAddress, 1, 2, true); + const labeledBucketData = bucket.getOrCreateData(); + + for (let firstDim = 0; firstDim < Constants.BUCKET_WIDTH; firstDim++) { + for (let secondDim = 0; secondDim < Constants.BUCKET_WIDTH; secondDim++) { + const addr = cube.getVoxelIndex([firstDim, secondDim, Z], 0); + expect(labeledBucketData[addr]).toBe(expectedBucketData[addr]); + } + } + }); + + it("A labeledVoxelMap should be applied correctly (ignore values outside of bbox)", () => { + const { cube } = context; + const bucket = cube.getOrCreateBucket([0, 0, 0, 0]); + assertNonNullBucket(bucket); + const labeledVoxelsMap: LabeledVoxelsMap = new Map(); + const voxelMap = getEmptyVoxelMap(); + const voxelsToLabel = [ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + [2, 0], + [2, 1], + [2, 2], + [3, 0], + ]; + const Z = 5; voxelsToLabel.forEach(([firstDim, secondDim]) => labelVoxelInVoxelMap(firstDim, secondDim, voxelMap), ); @@ -623,12 +661,20 @@ describe("Volume Annotation Sampling", () => { const get3DAddress = (x: number, y: number, out: Vector3 | Float32Array) => { out[0] = x; out[1] = y; - out[2] = 5; + out[2] = Z; }; const expectedBucketData = new Uint32Array(Constants.BUCKET_SIZE).fill(0); voxelsToLabel.forEach(([firstDim, secondDim]) => { - const addr = cube.getVoxelIndex([firstDim, secondDim, 5], 0); + if ( + firstDim < cubeBoundingBox.min[0] || + secondDim < cubeBoundingBox.min[1] || + firstDim >= cubeBoundingBox.max[0] || + secondDim >= cubeBoundingBox.max[1] + ) { + return; + } + const addr = cube.getVoxelIndex([firstDim, secondDim, Z], 0); expectedBucketData[addr] = 1; }); applyVoxelMap(labeledVoxelsMap, cube, 1, get3DAddress, 1, 2, true); @@ -636,7 +682,47 @@ describe("Volume Annotation Sampling", () => { for (let firstDim = 0; firstDim < Constants.BUCKET_WIDTH; firstDim++) { for (let secondDim = 0; secondDim < Constants.BUCKET_WIDTH; secondDim++) { - const addr = cube.getVoxelIndex([firstDim, secondDim, 5], 0); + const addr = cube.getVoxelIndex([firstDim, secondDim, Z], 0); + expect(labeledBucketData[addr]).toBe(expectedBucketData[addr]); + } + } + }); + + it("A labeledVoxelMap should be applied correctly (ignore all values outside of z)", () => { + const { cube } = context; + const bucket = cube.getOrCreateBucket([0, 0, 0, 0]); + assertNonNullBucket(bucket); + const labeledVoxelsMap: LabeledVoxelsMap = new Map(); + const voxelMap = getEmptyVoxelMap(); + const voxelsToLabel = [ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + [2, 0], + [2, 1], + [2, 2], + [3, 0], + ]; + const Z = 0; + voxelsToLabel.forEach(([firstDim, secondDim]) => + labelVoxelInVoxelMap(firstDim, secondDim, voxelMap), + ); + labeledVoxelsMap.set(bucket.zoomedAddress, voxelMap); + + const get3DAddress = (x: number, y: number, out: Vector3 | Float32Array) => { + out[0] = x; + out[1] = y; + out[2] = Z; + }; + + const expectedBucketData = new Uint32Array(Constants.BUCKET_SIZE).fill(0); + applyVoxelMap(labeledVoxelsMap, cube, 1, get3DAddress, 1, 2, true); + const labeledBucketData = bucket.getOrCreateData(); + + for (let firstDim = 0; firstDim < Constants.BUCKET_WIDTH; firstDim++) { + for (let secondDim = 0; secondDim < Constants.BUCKET_WIDTH; secondDim++) { + const addr = cube.getVoxelIndex([firstDim, secondDim, Z], 0); expect(labeledBucketData[addr]).toBe(expectedBucketData[addr]); } } diff --git a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts index f5649b3d371..95db39644d8 100644 --- a/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts +++ b/frontend/javascripts/test/sagas/volumetracing/volumetracing_saga_integration_1.spec.ts @@ -449,7 +449,7 @@ describe("Volume Tracing", () => { Group 2 Segment 3 Segment 4 - */ + */ Store.dispatch( setSegmentGroupsAction( [ diff --git a/frontend/javascripts/types/globals.d.ts b/frontend/javascripts/types/globals.d.ts index 7ff3de1d0c6..20408fb59fd 100644 --- a/frontend/javascripts/types/globals.d.ts +++ b/frontend/javascripts/types/globals.d.ts @@ -11,6 +11,7 @@ declare global { } } +// Typescript utility types: // https://stackoverflow.com/questions/49285864/is-there-a-valueof-similar-to-keyof-in-typescript export type ValueOf = T[keyof T]; export type EmptyObject = Record; @@ -21,3 +22,4 @@ export type ArrayElement = A extends readonly (infer T)[] ? T : never; export type Mutable = { -readonly [K in keyof T]: T[K]; }; +export type Writeable = { -readonly [P in keyof T]: T[P] }; diff --git a/frontend/javascripts/viewer/api/api_latest.ts b/frontend/javascripts/viewer/api/api_latest.ts index 2f26d77ab49..6a265ee2da1 100644 --- a/frontend/javascripts/viewer/api/api_latest.ts +++ b/frontend/javascripts/viewer/api/api_latest.ts @@ -13,17 +13,19 @@ import type { ToastStyle } from "libs/toast"; import Toast from "libs/toast"; import UserLocalStorage from "libs/user_local_storage"; import * as Utils from "libs/utils"; -import { coalesce } from "libs/utils"; +import { coalesce, mod } from "libs/utils"; import window, { location } from "libs/window"; import _ from "lodash"; import messages from "messages"; import TWEEN from "tween.js"; import { type APICompoundType, APICompoundTypeEnum, type ElementClass } from "types/api_types"; import type { AdditionalCoordinate } from "types/api_types"; +import type { Writeable } from "types/globals"; import type { BoundingBoxType, BucketAddress, ControlMode, + LabeledVoxelsMap, OrthoView, TypedArray, Vector3, @@ -72,6 +74,7 @@ import { } from "viewer/model/accessors/skeletontracing_accessor"; import { AnnotationTool, type AnnotationToolId } from "viewer/model/accessors/tool_accessor"; import { + enforceActiveVolumeTracing, getActiveCellId, getActiveSegmentationTracing, getNameOfRequestedOrVisibleSegmentationLayer, @@ -134,6 +137,7 @@ import { type BatchableUpdateSegmentAction, batchUpdateGroupsAndSegmentsAction, clickSegmentAction, + finishAnnotationStrokeAction, removeSegmentAction, setActiveCellAction, setSegmentGroupsAction, @@ -142,6 +146,7 @@ import { import BoundingBox from "viewer/model/bucket_data_handling/bounding_box"; import type { Bucket, DataBucket } from "viewer/model/bucket_data_handling/bucket"; import type DataLayer from "viewer/model/data_layer"; +import Dimensions from "viewer/model/dimensions"; import dimensions from "viewer/model/dimensions"; import { MagInfo } from "viewer/model/helpers/mag_info"; import { parseNml } from "viewer/model/helpers/nml_helpers"; @@ -151,10 +156,13 @@ import { globalPositionToBucketPosition, scaleGlobalPositionWithMagnification, zoomedAddressToZoomedPosition, + zoomedPositionToZoomedAddress, } from "viewer/model/helpers/position_converter"; import { getConstructorForElementClass } from "viewer/model/helpers/typed_buffer"; import { getMaximumGroupId } from "viewer/model/reducers/skeletontracing_reducer_helpers"; import { getHalfViewportExtentsInUnitFromState } from "viewer/model/sagas/saga_selectors"; +import { applyLabeledVoxelMapToAllMissingMags } from "viewer/model/sagas/volume/helpers"; +import { applyVoxelMap } from "viewer/model/volumetracing/volume_annotation_sampling"; import { Model, api } from "viewer/singletons"; import type { DatasetConfiguration, @@ -412,7 +420,8 @@ class TracingApi { * @example * const comment = api.tracing.getCommentForNode(23); * - * @example // Provide a tree for lookup speed boost + * @example + * // Provide a tree for lookup speed boost * const comment = api.tracing.getCommentForNode(23, api.getActiveTreeid()); */ getCommentForNode(nodeId: number, treeId?: number): string | null | undefined { @@ -1814,14 +1823,17 @@ class DataApi { * If the zoom level is provided and points to a not existent mag, * 0 will be returned. * - * @example // Return the greyscale value for a bucket + * @example + * // Return the greyscale value for a bucket * const position = [123, 123, 123]; * api.data.getDataValue("binary", position).then((greyscaleColor) => ...); * - * @example // Using the await keyword instead of the promise syntax + * @example + * // Using the await keyword instead of the promise syntax * const greyscaleColor = await api.data.getDataValue("binary", position); * - * @example // Get the segmentation id for the first volume tracing layer + * @example + * // Get the segmentation id for the first volume tracing layer * const segmentId = await api.data.getDataValue(api.data.getVolumeTracingLayerIds()[0], position); */ async getDataValue( @@ -1995,7 +2007,7 @@ class DataApi { bucketPositionToGlobalAddress(bucketAddress, new MagInfo(magnifications)); const nextBucketInDim = (bucket: BucketAddress, dim: 0 | 1 | 2) => { - const copy = bucket.slice() as BucketAddress; + const copy = bucket.slice() as Writeable; copy[dim]++; return copy; }; @@ -2120,7 +2132,8 @@ class DataApi { * Downloads a cuboid of raw data from a dataset (not tracing) layer. A new window is opened for the download - * if that is not the case, please check your pop-up blocker. * - * @example // Download a cuboid (from (0, 0, 0) to (100, 200, 100)) of raw data from the "segmentation" layer. + * @example + * // Download a cuboid (from (0, 0, 0) to (100, 200, 100)) of raw data from the "segmentation" layer. * api.data.downloadRawDataCuboid("segmentation", [0,0,0], [100,200,100]); */ downloadRawDataCuboid(layerName: string, topLeft: Vector3, bottomRight: Vector3): Promise { @@ -2156,32 +2169,115 @@ class DataApi { } /** - * Label voxels with the supplied value. Note that this method does not mutate - * the data immediately, but instead returns a promise (since the data might - * have to be downloaded first). + * Label voxels with the supplied value. This method behaves as if the user + * had brushed the provided voxels all at once. If the volume data wasn't + * downloaded completely yet, WEBKNOSSOS will merge the data as soon as it + * was downloaded. * * _Volume tracing only!_ * - * @example // Set the segmentation id for some voxels to 1337 - * await api.data.labelVoxels([[1,1,1], [1,2,1], [2,1,1], [2,2,1]], 1337); + * @example + * // Set the segmentation id for some voxels to 1337 + * api.data.labelVoxels([[1,1,1], [1,2,1], [2,1,1], [2,2,1]], 1337); */ - async labelVoxels( - voxels: Array, - label: number, - additionalCoordinates: AdditionalCoordinate[] | null = null, - ): Promise { - assertVolume(Store.getState()); - const segmentationLayer = this.model.getEnforcedSegmentationTracingLayer(); - await Promise.all( - voxels.map((voxel) => - segmentationLayer.cube._labelVoxelInAllResolutions_DEPRECATED( - voxel, + labelVoxels( + globalPositionsMag1: Vector3[], + segmentId: number, + optAdditionalCoordinates?: AdditionalCoordinate[] | null, + ) { + const state = Store.getState(); + const allowUpdate = state.annotation.restrictions.allowUpdate; + const additionalCoordinates = + optAdditionalCoordinates === undefined + ? state.flycam.additionalCoordinates + : optAdditionalCoordinates; + if (!allowUpdate || globalPositionsMag1.length === 0) return; + + const volumeTracing = enforceActiveVolumeTracing(state); + const segmentationLayer = Model.getSegmentationTracingLayer(volumeTracing.tracingId); + const { cube } = segmentationLayer; + const magInfo = getMagInfo(segmentationLayer.mags); + const labeledZoomStep = magInfo.getClosestExistingIndex(0); + const labeledMag = magInfo.getMagByIndexOrThrow(labeledZoomStep); + const dimensionIndices = Dimensions.getIndices(OrthoViews.PLANE_XY); + const thirdDim = dimensionIndices[2]; + + const globalPositions = globalPositionsMag1.map( + (pos): Vector3 => [ + Math.floor(pos[0] / labeledMag[0]), + Math.floor(pos[1] / labeledMag[1]), + Math.floor(pos[2] / labeledMag[2]), + ], + ); + const groupedByW = _.groupBy(globalPositions, (pos) => pos[thirdDim]); + + for (const group of Object.values(groupedByW)) { + const w = group[0][thirdDim]; + const currentLabeledVoxelMap: LabeledVoxelsMap = new Map(); + + for (const pos of group) { + const bucketZoomedAddress = zoomedPositionToZoomedAddress( + pos, + labeledZoomStep, additionalCoordinates, - label, - ), + ); + + let labelMap = currentLabeledVoxelMap.get(bucketZoomedAddress); + if (!labelMap) { + labelMap = new Uint8Array(Constants.BUCKET_WIDTH ** 2); + currentLabeledVoxelMap.set(bucketZoomedAddress, labelMap); + } + + const a = pos[dimensionIndices[0]]; + const b = pos[dimensionIndices[1]]; + const localA = mod(a, Constants.BUCKET_WIDTH); + const localB = mod(b, Constants.BUCKET_WIDTH); + labelMap[localA * Constants.BUCKET_WIDTH + localB] = 1; + } + + const numberOfSlices = 1; + applyVoxelMap( + currentLabeledVoxelMap, + cube, + segmentId, + (x, y, out) => { + out[0] = x; + out[1] = y; + out[2] = w; + }, + numberOfSlices, + thirdDim, + true, + 0, + ); + + const thirdDimensionOfSlice = w * labeledMag[thirdDim]; + + applyLabeledVoxelMapToAllMissingMags( + currentLabeledVoxelMap, + labeledZoomStep, + dimensionIndices, + magInfo, + cube, + segmentId, + thirdDimensionOfSlice, + true, + 0, + ); + } + + Store.dispatch( + updateSegmentAction( + segmentId, + { + somePosition: globalPositionsMag1[0], + someAdditionalCoordinates: additionalCoordinates || undefined, + }, + volumeTracing.tracingId, ), ); - segmentationLayer.cube.pushQueue.push(); + + Store.dispatch(finishAnnotationStrokeAction(volumeTracing.tracingId)); } /** @@ -2741,7 +2837,8 @@ class UtilsApi { /** * Wait for some milliseconds before continuing the control flow. * - * @example // Wait for 5 seconds + * @example + * // Wait for 5 seconds * await api.utils.sleep(5000); */ sleep(milliseconds: number): Promise { @@ -2756,7 +2853,8 @@ class UtilsApi { * @param {string} type - Can be one of the following: "info", "warning", "success" or "error" * @param {string} message - The message string you want to show * @param {number} timeout - Time period in milliseconds after which the toast will be hidden. Time is measured as soon as the user moves the mouse. A value of 0 means that the toast will only hide by clicking on it's X button. - * @example // Show a toast for 5 seconds + * @example + * // Show a toast for 5 seconds * const removeToast = api.utils.showToast("info", "You just got toasted", false, 5000); * // ... optionally: * // removeToast(); diff --git a/frontend/javascripts/viewer/api/wk_dev.ts b/frontend/javascripts/viewer/api/wk_dev.ts index 211c40633e1..4c78549496b 100644 --- a/frontend/javascripts/viewer/api/wk_dev.ts +++ b/frontend/javascripts/viewer/api/wk_dev.ts @@ -4,7 +4,7 @@ import { V3 } from "libs/mjs"; import { roundTo, sleep } from "libs/utils"; import _ from "lodash"; import { type OrthoView, OrthoViews, type Vector3 } from "viewer/constants"; -import { Store } from "viewer/singletons"; +import { Model, Store } from "viewer/singletons"; import type { ApiInterface } from "./api_latest"; import type ApiLoader from "./api_loader"; @@ -59,6 +59,11 @@ export default class WkDev { return Store; } + public get model() { + /* Access to the model */ + return Model; + } + public get api() { /* Access to the API (will fail if API is not initialized yet). */ if (this._api == null) { diff --git a/frontend/javascripts/viewer/constants.ts b/frontend/javascripts/viewer/constants.ts index 3d341a73337..c5738981884 100644 --- a/frontend/javascripts/viewer/constants.ts +++ b/frontend/javascripts/viewer/constants.ts @@ -20,8 +20,8 @@ export type NestedMatrix4 = [Vector4, Vector4, Vector4, Vector4]; // Represents // For 3D data BucketAddress = x, y, z, mag // For higher dimensional data, BucketAddress = x, y, z, mag, [{name: "t", value: t}, ...] export type BucketAddress = - | Vector4 - | [number, number, number, number, AdditionalCoordinate[] | null]; + | readonly [number, number, number, number] + | readonly [number, number, number, number, AdditionalCoordinate[] | null]; export type Point2 = { x: number; @@ -49,11 +49,11 @@ export type Rect = { height: number; }; export const AnnotationContentTypes = ["skeleton", "volume", "hybrid"]; -export const Vector2Indicies = [0, 1]; -export const Vector3Indicies = [0, 1, 2]; -export const Vector4Indicies = [0, 1, 2, 3]; -export const Vector5Indicies = [0, 1, 2, 3, 4]; -export const Vector6Indicies = [0, 1, 2, 3, 4, 5]; +export const Vector2Indicies = [0, 1] as const; +export const Vector3Indicies = [0, 1, 2] as const; +export const Vector4Indicies = [0, 1, 2, 3] as const; +export const Vector5Indicies = [0, 1, 2, 3, 4] as const; +export const Vector6Indicies = [0, 1, 2, 3, 4, 5] as const; export enum OrthoViews { PLANE_XY = "PLANE_XY", PLANE_YZ = "PLANE_YZ", diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts b/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts index 33446ab82b7..32de78b782b 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/bounding_box.ts @@ -2,26 +2,22 @@ import { V3 } from "libs/mjs"; import { map3, mod } from "libs/utils"; import _ from "lodash"; import type { BoundingBoxType, OrthoView, Vector2, Vector3, Vector4 } from "viewer/constants"; -import constants, { Vector3Indicies } from "viewer/constants"; +import constants from "viewer/constants"; import type { BoundingBoxObject } from "viewer/store"; import Dimensions from "../dimensions"; -import type { MagInfo } from "../helpers/mag_info"; class BoundingBox { + // Min is including, max is excluding min: Vector3; max: Vector3; constructor(boundingBox: BoundingBoxType | null | undefined) { - // Min is including - this.min = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]; - // Max is excluding - this.max = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; - - if (boundingBox != null) { - for (const i of Vector3Indicies) { - this.min[i] = Math.max(this.min[i], boundingBox.min[i]); - this.max[i] = Math.min(this.max[i], boundingBox.max[i]); - } + if (boundingBox == null) { + this.min = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]; + this.max = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; + } else { + this.min = boundingBox.min.slice() as Vector3; + this.max = boundingBox.max.slice() as Vector3; } } @@ -42,34 +38,33 @@ class BoundingBox { return [u, v]; } - getBoxForZoomStep = _.memoize((mag: Vector3): BoundingBoxType => { - // No `map` for performance reasons - const min = [0, 0, 0] as Vector3; - const max = [0, 0, 0] as Vector3; - - for (let i = 0; i < 3; i++) { - const divisor = constants.BUCKET_WIDTH * mag[i]; - min[i] = Math.floor(this.min[i] / divisor); - max[i] = Math.ceil(this.max[i] / divisor); - } - - return { - min, - max, - }; - }); + static fromBucketAddress(address: Vector4, mag: Vector3): BoundingBox { + return new BoundingBox(this.fromBucketAddressFast(address, mag)); + } - containsBucket([x, y, z, zoomStep]: Vector4, magInfo: MagInfo): boolean { - /* Checks whether a bucket is contained in the active bounding box. - * If the passed magInfo does not contain the passed zoomStep, this method - * returns false. + static fromBucketAddressFast( + [x, y, z, _zoomStep]: Vector4, + mag: Vector3, + ): { min: Vector3; max: Vector3 } { + /* + The fast variant does not allocate a Bounding Box instance which can be helpful for tight loops. */ - const magIndex = magInfo.getMagByIndex(zoomStep); - if (magIndex == null) { - return false; - } - const { min, max } = this.getBoxForZoomStep(magIndex); - return min[0] <= x && x < max[0] && min[1] <= y && y < max[1] && min[2] <= z && z < max[2]; + const bucketSize = constants.BUCKET_WIDTH; + + // Precompute scaled sizes once + const sx = bucketSize * mag[0]; + const sy = bucketSize * mag[1]; + const sz = bucketSize * mag[2]; + + // Bucket bounds in world space + const bxMin = x * sx; + const byMin = y * sy; + const bzMin = z * sz; + const bxMax = bxMin + sx; + const byMax = byMin + sy; + const bzMax = bzMin + sz; + + return { min: [bxMin, byMin, bzMin], max: [bxMax, byMax, bzMax] }; } containsPoint(vec3: Vector3) { @@ -87,6 +82,13 @@ class BoundingBox { } intersectedWith(other: BoundingBox): BoundingBox { + return new BoundingBox(this.intersectedWithFast(other)); + } + + intersectedWithFast(other: { min: Vector3; max: Vector3 }): { min: Vector3; max: Vector3 } { + /* + The fast variant does not allocate a Bounding Box instance which can be helpful for tight loops. + */ const newMin = V3.max(this.min, other.min); const uncheckedMax = V3.min(this.max, other.max); @@ -94,10 +96,10 @@ class BoundingBox { // extent. const newMax = V3.max(newMin, uncheckedMax); - return new BoundingBox({ + return { min: newMin, max: newMax, - }); + }; } extend(other: BoundingBox): BoundingBox { diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts index 990ddb984cd..f3128793b5d 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/bucket.ts @@ -16,6 +16,7 @@ import type TemporalBucketManager from "viewer/model/bucket_data_handling/tempor import { bucketPositionToGlobalAddress } from "viewer/model/helpers/position_converter"; import Store from "viewer/store"; import { getActiveMagIndexForLayer } from "../accessors/flycam_accessor"; +import Dimensions from "../dimensions"; import { getConstructorForElementClass, uint8ToTypedBuffer } from "../helpers/typed_buffer"; import BucketSnapshot, { type PendingOperation } from "./bucket_snapshot"; @@ -76,6 +77,21 @@ export function markVolumeTransactionEnd() { bucketsAlreadyInUndoState.clear(); } +export type SomeContainment = + | { type: "full" } + | { + type: "partial"; + // min is inclusive + min: Vector3; + // max is exclusive + max: Vector3; + }; +export type Containment = + | SomeContainment + | { + type: "no"; + }; + export class DataBucket { readonly type = "data" as const; readonly elementClass: ElementClass; @@ -112,6 +128,7 @@ export class DataBucket { elementClass: ElementClass, zoomedAddress: BucketAddress, temporalBucketManager: TemporalBucketManager, + public containment: SomeContainment, cube: DataCube, ) { this.emitter = createNanoEvents(); @@ -454,7 +471,7 @@ export class DataBucket { voxelMap: Uint8Array, segmentId: number, get3DAddress: (arg0: number, arg1: number, arg2: Vector3 | Float32Array) => void, - sliceCount: number, + sliceOffset: number, thirdDimensionIndex: 0 | 1 | 2, // If shouldOverwrite is false, a voxel is only overwritten if // its old value is equal to overwritableValue. shouldOverwrite: boolean = true, @@ -473,7 +490,7 @@ export class DataBucket { voxelMap, segmentId, get3DAddress, - sliceCount, + sliceOffset, thirdDimensionIndex, shouldOverwrite, overwritableValue, @@ -486,7 +503,7 @@ export class DataBucket { voxelMap, segmentId, get3DAddress, - sliceCount, + sliceOffset, thirdDimensionIndex, shouldOverwrite, overwritableValue, @@ -498,8 +515,9 @@ export class DataBucket { voxelMap: Uint8Array, uncastSegmentId: number, get3DAddress: (arg0: number, arg1: number, arg2: Vector3 | Float32Array) => void, - sliceCount: number, - thirdDimensionIndex: 0 | 1 | 2, // If shouldOverwrite is false, a voxel is only overwritten if + sliceOffset: number, + thirdDimensionIndex: 0 | 1 | 2, + // If shouldOverwrite is false, a voxel is only overwritten if // its old value is equal to overwritableValue. shouldOverwrite: boolean = true, overwritableValue: number = 0, @@ -509,13 +527,42 @@ export class DataBucket { const segmentId = castForArrayType(uncastSegmentId, data); - for (let firstDim = 0; firstDim < Constants.BUCKET_WIDTH; firstDim++) { - for (let secondDim = 0; secondDim < Constants.BUCKET_WIDTH; secondDim++) { + const limits = { + u: { min: 0, max: Constants.BUCKET_WIDTH }, + v: { min: 0, max: Constants.BUCKET_WIDTH }, + w: { min: 0, max: Constants.BUCKET_WIDTH }, + }; + + if (this.containment.type === "partial") { + const plane = Dimensions.planeForThirdDimension(thirdDimensionIndex); + const [u, v, w] = Dimensions.getIndices(plane); + limits.u.min = this.containment.min[u]; + limits.u.max = this.containment.max[u]; + + limits.v.min = this.containment.min[v]; + limits.v.max = this.containment.max[v]; + + limits.w.min = this.containment.min[w]; + limits.w.max = this.containment.max[w]; + } + + for (let firstDim = limits.u.min; firstDim < limits.u.max; firstDim++) { + for (let secondDim = limits.v.min; secondDim < limits.v.max; secondDim++) { if (voxelMap[firstDim * Constants.BUCKET_WIDTH + secondDim] === 1) { get3DAddress(firstDim, secondDim, out); const voxelToLabel = out; voxelToLabel[thirdDimensionIndex] = - (voxelToLabel[thirdDimensionIndex] + sliceCount) % Constants.BUCKET_WIDTH; + (voxelToLabel[thirdDimensionIndex] + sliceOffset) % Constants.BUCKET_WIDTH; + + if ( + // The is-partial check is only done as a performance improvement. + this.containment.type === "partial" && + (voxelToLabel[thirdDimensionIndex] < limits.w.min || + voxelToLabel[thirdDimensionIndex] >= limits.w.max) + ) { + continue; + } + // The voxelToLabel is already within the bucket and in the correct magnification. const voxelAddress = this.cube.getVoxelIndexByVoxelOffset(voxelToLabel); const currentSegmentId = Number(data[voxelAddress]); diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts index 0a904d708eb..47b833dce9e 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/data_cube.ts @@ -1,11 +1,12 @@ import ErrorHandling from "libs/error_handling"; -import { V3 } from "libs/mjs"; +import { V3, V4 } from "libs/mjs"; import type { ProgressCallback } from "libs/progress_callback"; import Toast from "libs/toast"; import { areBoundingBoxesOverlappingOrTouching, castForArrayType, isNumberMap, + mod, union, } from "libs/utils"; import _ from "lodash"; @@ -18,12 +19,18 @@ import type { BucketAddress, LabelMasksByBucketAndW, Vector3, + Vector4, } from "viewer/constants"; import constants, { MappingStatusEnum } from "viewer/constants"; +import Constants from "viewer/constants"; import { getMappingInfo } from "viewer/model/accessors/dataset_accessor"; import { getSomeTracing } from "viewer/model/accessors/tracing_accessor"; import BoundingBox from "viewer/model/bucket_data_handling/bounding_box"; -import type { Bucket } from "viewer/model/bucket_data_handling/bucket"; +import type { + Bucket, + Containment, + SomeContainment, +} from "viewer/model/bucket_data_handling/bucket"; import { DataBucket, NULL_BUCKET, NullBucket } from "viewer/model/bucket_data_handling/bucket"; import type PullQueue from "viewer/model/bucket_data_handling/pullqueue"; import type PushQueue from "viewer/model/bucket_data_handling/pushqueue"; @@ -47,7 +54,7 @@ const warnAboutTooManyAllocations = _.once(() => { }); class CubeEntry { - data: Map; + data: Map; boundary: Vector3; constructor(boundary: Vector3) { @@ -68,6 +75,27 @@ class CubeEntry { const FLOODFILL_VOXEL_THRESHOLD = 5 * 1000000; const USE_FLOODFILL_VOXEL_THRESHOLD = false; +const NoContainment = { type: "no" } as const; +const FullContainment = { type: "full" } as const; + +const zeroToBucketWidth = (el: number) => { + return el !== 0 ? el : Constants.BUCKET_WIDTH; +}; +const makeLocalMin = (min: Vector3, mag: Vector3): Vector3 => { + return [ + mod(Math.floor(min[0] / mag[0]), Constants.BUCKET_WIDTH), + mod(Math.floor(min[1] / mag[1]), Constants.BUCKET_WIDTH), + mod(Math.floor(min[2] / mag[2]), Constants.BUCKET_WIDTH), + ]; +}; +const makeLocalMax = (max: Vector3, mag: Vector3): Vector3 => { + return [ + zeroToBucketWidth(mod(Math.ceil(max[0] / mag[0]), Constants.BUCKET_WIDTH)), + zeroToBucketWidth(mod(Math.ceil(max[1] / mag[1]), Constants.BUCKET_WIDTH)), + zeroToBucketWidth(mod(Math.ceil(max[2] / mag[2]), Constants.BUCKET_WIDTH)), + ]; +}; + class DataCube { BUCKET_COUNT_SOFT_LIMIT = constants.MAXIMUM_BUCKET_COUNT_PER_LAYER; buckets: Array; @@ -206,27 +234,60 @@ class DataCube { } private getCubeKey(zoomStep: number, allCoords: AdditionalCoordinate[] | undefined | null) { - const relevantCoords = (allCoords ?? []).filter( - (coord) => this.additionalAxes[coord.name] != null, - ); + if (allCoords == null) { + // Instead of defaulting from null to [] for allCoords, we early-out with this simple + // return value for performance reasons. + return `${zoomStep}`; + } + const relevantCoords = allCoords.filter((coord) => this.additionalAxes[coord.name] != null); return [zoomStep, ...relevantCoords.map((el) => el.value)].join("-"); } - isWithinBounds([x, y, z, zoomStep, coords]: BucketAddress): boolean { + private checkContainment([x, y, z, zoomStep, coords]: BucketAddress): Containment { const cube = this.getOrCreateCubeEntry(zoomStep, coords); if (cube == null) { - return false; + return NoContainment; } - return this.boundingBox.containsBucket([x, y, z, zoomStep], this.magInfo); + const mag = this.magInfo.getMagByIndex(zoomStep); + if (mag == null) { + return NoContainment; + } + + const bucketBBox = BoundingBox.fromBucketAddressFast([x, y, z, zoomStep], mag); + if (bucketBBox == null) { + return NoContainment; + } + + const intersectionBBox = this.boundingBox.intersectedWithFast(bucketBBox); + + if ( + intersectionBBox.min[0] === intersectionBBox.max[0] || + intersectionBBox.min[1] === intersectionBBox.max[1] || + intersectionBBox.min[2] === intersectionBBox.max[2] + ) { + return NoContainment; + } + if ( + V3.equals(intersectionBBox.min, bucketBBox.min) && + V3.equals(intersectionBBox.max, bucketBBox.max) + ) { + return FullContainment; + } + + const { min, max } = intersectionBBox; + + return { + type: "partial", + min: makeLocalMin(min, mag), + max: makeLocalMax(max, mag), + }; } getBucketIndexAndCube([x, y, z, zoomStep, coords]: BucketAddress): [ number | null | undefined, CubeEntry | null, ] { - // Removed for performance reasons - // ErrorHandling.assert(this.isWithinBounds([x, y, z, zoomStep])); const cube = this.getOrCreateCubeEntry(zoomStep, coords); if (cube != null) { @@ -272,31 +333,36 @@ class DataCube { // NULL_BUCKET if the bucket cannot possibly exist, e.g. because it is // outside the dataset's bounding box. getOrCreateBucket(address: BucketAddress): Bucket { - if (!this.isWithinBounds(address)) { - return this.getNullBucket(); - } - - let bucket = this.getBucket(address, true); + let bucket = this.getBucket(address); if (bucket instanceof NullBucket) { - bucket = this.createBucket(address); + const containment = this.checkContainment(address); + if (containment.type === "no") { + return this.getNullBucket(); + } + bucket = this.createBucket(address, containment); } return bucket; } // Returns the Bucket object if it exists, or NULL_BUCKET otherwise. - getBucket(address: BucketAddress, skipBoundsCheck: boolean = false): Bucket { - if (!skipBoundsCheck && !this.isWithinBounds(address)) { - return this.getNullBucket(); - } - + getBucket(address: BucketAddress): Bucket { const [bucketIndex, cube] = this.getBucketIndexAndCube(address); if (bucketIndex != null && cube != null) { const bucket = cube.data.get(bucketIndex); - if (bucket != null) { + // We double-check that the address of the bucket matches the requested + // address. If the address is outside of the layer's bbox, the linearization + // of the address into one index might collide with another bucket (that is + // within the bbox) which is why the check is necessary. + // We use slice to ignore the additional coordinates (this is mostly done + // to ignore annoying cases like null vs [] which have identical semantics). + if ( + bucket != null && + V4.isEqual(address.slice(0, 4) as Vector4, bucket.zoomedAddress.slice(0, 4) as Vector4) + ) { return bucket; } } @@ -304,8 +370,14 @@ class DataCube { return this.getNullBucket(); } - createBucket(address: BucketAddress): Bucket { - const bucket = new DataBucket(this.elementClass, address, this.temporalBucketManager, this); + createBucket(address: BucketAddress, containment: SomeContainment): Bucket { + const bucket = new DataBucket( + this.elementClass, + address, + this.temporalBucketManager, + containment, + this, + ); this.addBucketToGarbageCollection(bucket); const [bucketIndex, cube] = this.getBucketIndexAndCube(address); @@ -456,33 +528,6 @@ class DataCube { } } - async _labelVoxelInAllResolutions_DEPRECATED( - voxel: Vector3, - additionalCoordinates: AdditionalCoordinate[] | null, - label: number, - activeSegmentId?: number | null | undefined, - ): Promise { - // This function is only provided for the wK front-end api and should not be used internally, - // since it only operates on one voxel and therefore is not performance-optimized. - // Please make use of a LabeledVoxelsMap instead. - const promises = []; - - for (const [magIndex] of this.magInfo.getMagsWithIndices()) { - promises.push( - this._labelVoxelInResolution_DEPRECATED( - voxel, - additionalCoordinates, - label, - magIndex, - activeSegmentId, - ), - ); - } - - await Promise.all(promises); - this.triggerPushQueue(); - } - async _labelVoxelInResolution_DEPRECATED( voxel: Vector3, additionalCoordinates: AdditionalCoordinate[] | null, @@ -490,6 +535,10 @@ class DataCube { zoomStep: number, activeSegmentId: number | null | undefined, ): Promise { + // This function is only provided for testing purposes and should not be used internally, + // since it only operates on one voxel and therefore is not performance-optimized. It should + // be refactored away. + // Please make use of a LabeledVoxelsMap instead. const voxelInCube = this.boundingBox.containsPoint(voxel); if (voxelInCube) { diff --git a/frontend/javascripts/viewer/model/bucket_data_handling/pullqueue.ts b/frontend/javascripts/viewer/model/bucket_data_handling/pullqueue.ts index 67cd7564b42..59664db0c5a 100644 --- a/frontend/javascripts/viewer/model/bucket_data_handling/pullqueue.ts +++ b/frontend/javascripts/viewer/model/bucket_data_handling/pullqueue.ts @@ -31,6 +31,7 @@ class PullQueue { private abortController: AbortController; private consecutiveErrorCount: number; private isRetryScheduled: boolean; + private isDestroyed: boolean = false; constructor(cube: DataCube, layerName: string, datastoreInfo: DataStoreInfo) { this.cube = cube; @@ -105,6 +106,9 @@ class PullQueue { } } } catch (error) { + if (this.isDestroyed) { + return; + } for (const bucketAddress of batch) { const bucket = this.cube.getBucket(bucketAddress); @@ -197,6 +201,12 @@ class PullQueue { this.priorityQueue.queue(el); } } + + destroy() { + this.isDestroyed = true; + this.clear(); + this.abortRequests(); + } } export default PullQueue; diff --git a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts index 8486bb7d7f0..073efc73f94 100644 --- a/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts +++ b/frontend/javascripts/viewer/model/reducers/reducer_helpers.ts @@ -74,7 +74,6 @@ export function convertFrontendBoundingBoxToServer( }; } -// Currently unused. export function convertPointToVecInBoundingBox(boundingBox: ServerBoundingBox): BoundingBoxObject { return { width: boundingBox.width, diff --git a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts index d6f02dbb193..f5d4ea3cae4 100644 --- a/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/volumetracing_reducer.ts @@ -175,6 +175,9 @@ function handleRemoveSegment(state: WebknossosState, action: RemoveSegmentAction function handleUpdateSegment(state: WebknossosState, action: UpdateSegmentAction) { return updateSegments(state, action.layerName, (segments) => { const { segmentId, segment } = action; + if (segmentId === 0) { + return segments; + } const oldSegment = segments.getNullable(segmentId); let somePosition; diff --git a/frontend/javascripts/viewer/model/volumetracing/volume_annotation_sampling.ts b/frontend/javascripts/viewer/model/volumetracing/volume_annotation_sampling.ts index 9217c92b349..e41f4e87b3b 100644 --- a/frontend/javascripts/viewer/model/volumetracing/volume_annotation_sampling.ts +++ b/frontend/javascripts/viewer/model/volumetracing/volume_annotation_sampling.ts @@ -1,6 +1,7 @@ import { map3 } from "libs/utils"; import _ from "lodash"; import messages from "messages"; +import type { Writeable } from "types/globals"; import type { BucketAddress, LabeledVoxelsMap, Vector3 } from "viewer/constants"; import constants from "viewer/constants"; import type { Bucket } from "viewer/model/bucket_data_handling/bucket"; @@ -340,12 +341,13 @@ export function applyVoxelMap( get3DAddress(0, 0, out); const thirdDimensionValueInBucket = out[2]; - for (let sliceCount = 0; sliceCount < numberOfSlicesToApply; sliceCount++) { - const newThirdDimValue = thirdDimensionValueInBucket + sliceCount; + for (let sliceOffset = 0; sliceOffset < numberOfSlicesToApply; sliceOffset++) { + const newThirdDimValue = thirdDimensionValueInBucket + sliceOffset; - if (sliceCount > 0 && newThirdDimValue % constants.BUCKET_WIDTH === 0) { + if (sliceOffset > 0 && newThirdDimValue % constants.BUCKET_WIDTH === 0) { // The current slice is in the next bucket in the third direction. - const nextBucketZoomedAddress: BucketAddress = [...labeledBucketZoomedAddress]; + const nextBucketZoomedAddress: Writeable = [...labeledBucketZoomedAddress]; + nextBucketZoomedAddress[thirdDimensionIndex]++; postprocessBucket(bucket); bucket = dataCube.getOrCreateBucket(nextBucketZoomedAddress); @@ -361,7 +363,7 @@ export function applyVoxelMap( voxelMap, segmentId, get3DAddress, - sliceCount, + sliceOffset, thirdDimensionIndex, shouldOverwrite, overwritableValue, diff --git a/frontend/javascripts/viewer/model_initialization.ts b/frontend/javascripts/viewer/model_initialization.ts index 9e3359a1d23..3e1c500bdc9 100644 --- a/frontend/javascripts/viewer/model_initialization.ts +++ b/frontend/javascripts/viewer/model_initialization.ts @@ -46,7 +46,6 @@ import UrlManager, { import { determineAllowedModes, getDataLayers, - getDatasetBoundingBox, getDatasetCenter, getLayerByName, getSegmentationLayerByName, @@ -118,6 +117,7 @@ import Store from "viewer/store"; import { doAllLayersHaveTheSameRotation } from "./model/accessors/dataset_layer_transformation_accessor"; import { setVersionNumberAction } from "./model/actions/save_actions"; import { + convertPointToVecInBoundingBox, convertServerAdditionalAxesToFrontEnd, convertServerAnnotationToFrontendAnnotation, } from "./model/reducers/reducer_helpers"; @@ -580,7 +580,6 @@ function getMergedDataLayersFromDatasetAndVolumeTracings( ); const fallbackLayer = fallbackLayerIndex > -1 ? originalLayers[fallbackLayerIndex] : null; - const boundingBox = getDatasetBoundingBox(dataset).asServerBoundingBox(); const mags = tracing.mags || []; const tracingHasMagList = mags.length > 0; let coordinateTransformsMaybe = {}; @@ -606,7 +605,7 @@ function getMergedDataLayersFromDatasetAndVolumeTracings( elementClass: tracing.elementClass, category: "segmentation", largestSegmentId: tracing.largestSegmentId, - boundingBox, + boundingBox: convertPointToVecInBoundingBox(tracing.boundingBox), resolutions: tracingMags, mappings: fallbackLayer != null && "mappings" in fallbackLayer ? fallbackLayer.mappings : undefined,