From 812407957e8685886fe9364102486372eca17d7e Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 21 Sep 2022 14:42:40 +0200 Subject: [PATCH] Allow custom colors for volume annotation segments (#6372) * [WIP] allow custom colors for volume annotation segments * pretty * include color in segment update actions * fix compile errors * backend tests * set color to none if no color was included in the update action * Custom segment colors with cuckoo hashing (#6417) * resurrect cuckoo table implementation * adapt CuckooTable to entries consisting of (number, vec3) and finish proof-of-concept for writing and reading that hash table for segment colors * fall back to old color generation if custom color could not be looked up * add UI to select segment color * correctly save segment color to back-end * hook up custom segment colors from store with texture * improve performance of cuckoo updates * fix isDisabled prop for interpolation button * convert usage of vec4 color to vec3 * remove type property for uniforms since it's not needed anymore since three r76 * adapt tests to new cuckootable interface * clean up a bit * fix cuckoo tests by using broader value range for tests and using uint32array instead of float * try to get uint32 texture working (int texture only works when defined as byte, but that will clip values to 8 bit) * dirty proof of concept for uint32 texture * restore custom color rendering * fix tests * DRY mocks in tests * more clean up * implement unset for cuckoo table to allow resetting a segment color * extend tests a bit * don't hardcode values for cuckoo shader code * fix linting * also show segment patterns for custom segment colors * show correct colors (even when mapped) in segment list * also use correct color for mesh and synchronize the mesh with the segment color * fix linting * clean up some typing * make custom segment colors compatible with multiple segmentation layers * fix linting * fix segment color icon for non-tracing layers * don't allow changing colors for segments of non-tracing segmentation layer * remove unused code for low-level texture setup * remove logging from cuckoo table implementation * clean up cuckoo_table.spec.ts * fix React does not recognize the prop on a DOM element. warning when opening context menu * don't rely on random IDs in merger mode * dont use the term color in the merger mode implementation as it is incorrect and confusing * remove shuffleColorOfCurrentTree functionality for merger mode (let's wait for user feedback to see whether this is really needed) * also reset stored representative when deleting last node of tree in merger mode * clean up some mapping related code * allow changing segment colors for non-tracing segmentation layers (necessary for json mappings); the colors won't be saved, though * use custom segment color feature when loading a mapping which defines hue values * clean up typing for withMappingActivationConfirmation * remove old custom color implementation for JSON mappings * remove attempt to integrate ACE editor * move the cuckoo table maintenance into the LayerRenderingManager to avoid redundant updates and ensure diffing with the correct segments * fix linting * fix that shader won't render when all volume layers are invisible (but at least one exists) * don't fill int texture with 255 by default * revert temporary changes in test.sh * skip shader tests which try to parse glsl 3000 syntax (and fail) * fix some tests by having isosurface-related sagas wait for the scene controller to be initialized (which doesn't happen in some tests); therefore fixing null access to the scene controller * skip mapping-related texture requirement test * fix that seeds were not initialized correctly when no segmentation is visible initially * explain why some glsl tests are skipped * remove isMappingSupported distinction and incorporate custom colors texture into rendering logic * fix skipped test in rendering logic spec * implement compaction for update segment UpdateActions * explain texture format etc better * clean up test * make getSegmentsForLayer non-nullable * fix shader tests by avoiding hex uint notation * explain isInt workaround * remove one window mock; enforce correct type of other window mock; remove lots of @ts errors * explain disabled logic * fix linting * fix linting * initialize color picker properly even when a custom segment color was not defined yet * don't share material for chunks of mesh, since the opacity has to be controlled individually for the loading animation * fix linting * fix crash when anchor position of segment is null * try to debug buggy restore * fix bug with repeated sets that caused a key to be appear multiple times; also add regression test * fix that mapping.mappingColors were still used in toolbar and segments tab; clean up * fix linting * avoid that all segments in segment list rerender when mouse is moved * make custom colors work when using setMapping via frontend API * add analytics for custom colors in setMapping * memoize diffing of segments; refactor memoization of diffing trees * add docs for segment list * remove obsolete type parameter for uniform Co-authored-by: Philipp Otto Co-authored-by: Philipp Otto --- CHANGELOG.unreleased.md | 1 + app/models/annotation/nml/NmlParser.scala | 16 +- app/models/annotation/nml/NmlWriter.scala | 1 + docs/volume_annotation.md | 14 + frontend/javascripts/components/loop.ts | 2 - frontend/javascripts/libs/UpdatableTexture.ts | 59 +++- frontend/javascripts/libs/error_handling.ts | 16 +- frontend/javascripts/libs/input.ts | 2 - frontend/javascripts/libs/mjs.ts | 16 +- frontend/javascripts/libs/utils.ts | 47 ++- frontend/javascripts/libs/window.ts | 9 +- frontend/javascripts/messages.tsx | 2 - frontend/javascripts/oxalis/api/api_latest.ts | 10 +- frontend/javascripts/oxalis/api/api_v2.ts | 4 - frontend/javascripts/oxalis/controller.tsx | 1 - .../oxalis/controller/scene_controller.ts | 32 +- .../oxalis/controller/url_manager.ts | 1 - .../geometries/materials/edge_shader.ts | 2 - .../geometries/materials/node_shader.ts | 12 - .../materials/plane_material_factory.ts | 109 +++--- .../plane_material_factory_helpers.ts | 35 +- frontend/javascripts/oxalis/merger_mode.ts | 117 ++---- frontend/javascripts/oxalis/model.ts | 5 +- .../model/accessors/dataset_accessor.ts | 2 + .../model/accessors/volumetracing_accessor.ts | 35 +- .../bucket_data_handling/cuckoo_table.ts | 334 ++++++++++++++++++ .../data_rendering_logic.tsx | 81 ++--- .../layer_rendering_manager.ts | 85 ++++- .../model/bucket_data_handling/mappings.ts | 68 +--- .../javascripts/oxalis/model/data_layer.ts | 22 +- .../helpers/compaction/compact_save_queue.ts | 31 +- .../model/reducers/volumetracing_reducer.ts | 20 +- .../oxalis/model/sagas/isosurface_saga.ts | 10 + .../oxalis/model/sagas/mapping_saga.ts | 96 +++-- .../model/sagas/skeletontracing_saga.ts | 25 +- .../oxalis/model/sagas/update_actions.ts | 6 + .../oxalis/model/sagas/volumetracing_saga.tsx | 25 +- .../oxalis/model_initialization.ts | 15 - .../oxalis/shaders/main_data_fragment.glsl.ts | 35 +- .../oxalis/shaders/segmentation.glsl.ts | 121 ++++--- .../javascripts/oxalis/shaders/utils.glsl.ts | 59 +++- frontend/javascripts/oxalis/store.ts | 3 +- .../oxalis/view/action-bar/save_button.tsx | 2 - .../oxalis/view/action-bar/toolbar_view.tsx | 49 ++- .../javascripts/oxalis/view/arbitrary_view.ts | 2 - .../javascripts/oxalis/view/context_menu.tsx | 46 ++- .../mapping_settings_view.tsx | 14 +- .../javascripts/oxalis/view/plane_view.ts | 2 - .../segments_tab/segment_list_item.tsx | 154 ++++++-- .../segments_tab/segments_view.tsx | 22 +- .../right-border-tabs/tree_hierarchy_view.tsx | 2 +- frontend/javascripts/router.tsx | 3 - .../javascripts/test/mocks/globals.mock.ts | 4 + .../test/mocks/updatable_texture.mock.ts | 49 +++ .../model/binary/data_rendering_logic.spec.ts | 34 +- .../test/model/cuckoo_table.spec.ts | 117 ++++++ .../test/model/texture_bucket_manager.spec.ts | 56 +-- .../test/shaders/shader_syntax.spec.ts | 7 +- frontend/javascripts/types/api_flow_types.ts | 3 +- .../VolumeUpdateActionsUnitTestSuite.scala | 2 + .../helpers/ProtoGeometryImplicits.scala | 10 +- .../proto/VolumeTracing.proto | 1 + .../tracings/NamedBoundingBox.scala | 2 +- .../updating/SkeletonUpdateActionHelper.scala | 12 +- .../updating/SkeletonUpdateActions.scala | 4 +- .../tracings/volume/VolumeUpdateActions.scala | 7 +- 66 files changed, 1497 insertions(+), 693 deletions(-) create mode 100644 frontend/javascripts/oxalis/model/bucket_data_handling/cuckoo_table.ts create mode 100644 frontend/javascripts/test/mocks/globals.mock.ts create mode 100644 frontend/javascripts/test/mocks/updatable_texture.mock.ts create mode 100644 frontend/javascripts/test/model/cuckoo_table.spec.ts diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index c19021f6a1f..0c3e0d8b28c 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -17,6 +17,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Added a context menu option to separate an agglomerate skeleton using Min-Cut. Activate the Proofreading tool, select the source node and open the context menu by right-clicking on the target node which you would like to separate through Min-Cut. [#6361](https://github.com/scalableminds/webknossos/pull/6361) - Added a "clear" button to reset skeletons/meshes after successful mergers/split. [#6459](https://github.com/scalableminds/webknossos/pull/6459) - The proofreading tool now supports merging and splitting (via min-cut) agglomerates by rightclicking a segment (and not a node). Note that there still has to be an active node so that both partners of the operation are defined. [#6464](https://github.com/scalableminds/webknossos/pull/6464) +- The color of a segments can now be changed in the segments tab. Rightclick a segment in the list and select "Change Color" to open a color picker. [#6372](https://github.com/scalableminds/webknossos/pull/6372) ### Changed - Selecting a node with the proofreading tool won't have any side effects anymore. Previous versions could load additional agglomerate skeletons in certain scenarios which could be confusing. [#6477](https://github.com/scalableminds/webknossos/pull/6477) diff --git a/app/models/annotation/nml/NmlParser.scala b/app/models/annotation/nml/NmlParser.scala index 252bc3fc553..da633a0aa2a 100755 --- a/app/models/annotation/nml/NmlParser.scala +++ b/app/models/annotation/nml/NmlParser.scala @@ -169,10 +169,11 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener case _ => None } Segment( - getSingleAttribute(node, "id").toLong, - anchorPosition, - getSingleAttributeOpt(node, "name"), - getSingleAttributeOpt(node, "created").flatMap(_.toLongOpt) + segmentId = getSingleAttribute(node, "id").toLong, + anchorPosition = anchorPosition, + name = getSingleAttributeOpt(node, "name"), + creationTime = getSingleAttributeOpt(node, "created").flatMap(_.toLongOpt), + color = parseColorOpt(node) ) }) @@ -195,7 +196,7 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener id <- idText.toIntOpt ?~ Messages("nml.boundingbox.id.invalid", idText) name = getSingleAttribute(node, "name") isVisible = getSingleAttribute(node, "isVisible").toBooleanOpt - color = parseColor(node) + color = parseColorOpt(node) boundingBox <- parseBoundingBox(node) nameOpt = if (name.isEmpty) None else Some(name) } yield NamedBoundingBoxProto(id, nameOpt, isVisible, color, boundingBox) @@ -295,9 +296,6 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener ColorProto(colorRed, colorBlue, colorGreen, colorAlpha) } - private def parseColor(node: XMLNode) = - parseColorOpt(node) - private def parseName(node: XMLNode) = getSingleAttribute(node, "name") @@ -315,7 +313,7 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener val treeIdText = getSingleAttribute(tree, "id") for { id <- treeIdText.toIntOpt ?~ Messages("nml.tree.id.invalid", treeIdText) - color = parseColor(tree) + color = parseColorOpt(tree) name = parseName(tree) groupId = parseGroupId(tree) isVisible = parseVisibility(tree, color) diff --git a/app/models/annotation/nml/NmlWriter.scala b/app/models/annotation/nml/NmlWriter.scala index aeb789d7ea2..08f336d8247 100644 --- a/app/models/annotation/nml/NmlWriter.scala +++ b/app/models/annotation/nml/NmlWriter.scala @@ -234,6 +234,7 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { writer.writeAttribute("anchorPositionY", a.y.toString) writer.writeAttribute("anchorPositionZ", a.z.toString) } + s.color.foreach(_ => writeColor(s.color)) } } } diff --git a/docs/volume_annotation.md b/docs/volume_annotation.md index b0ae940026f..eb1bbe5da23 100644 --- a/docs/volume_annotation.md +++ b/docs/volume_annotation.md @@ -36,6 +36,20 @@ In the `Segmentation` tab on the right-hand side panel, you can see the segment The active segment ID under the cursor can be found in the status bar at the bottom of the screen or through the context-sensitive menu on right-click. +### Segments List + +The right-hand side panel offers a `Segments` tab that lists segments and allows to edit these. +A segment is added to the list as soon as it was clicked in the data viewport. +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 +- loading meshes for the segments (ad-hoc and precomputed if available) +- changing the color of the segment +- activating the segment id (so that you can annotate with that id) + +![Segments Tab](images/segments_tab2.jpeg) + ### Merging volume annotation with fallback data After finishing the annotation of a volume layer with a fallback layer, the combined state of these layers can be materialized into a new dataset. For this, go to the layer settings in the left border tab. On the top right of the volume layer is the following button: diff --git a/frontend/javascripts/components/loop.ts b/frontend/javascripts/components/loop.ts index ff48fb48446..6d9ff70591e 100644 --- a/frontend/javascripts/components/loop.ts +++ b/frontend/javascripts/components/loop.ts @@ -9,13 +9,11 @@ class Loop extends Component { intervalId: number | null | undefined = null; componentDidMount() { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'setInterval' does not exist on type '(Wi... Remove this comment to see the full error message this.intervalId = window.setInterval(this.props.onTick, this.props.interval); } componentWillUnmount() { if (this.intervalId != null) { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'clearInterval' does not exist on type '(... Remove this comment to see the full error message window.clearInterval(this.intervalId); this.intervalId = null; } diff --git a/frontend/javascripts/libs/UpdatableTexture.ts b/frontend/javascripts/libs/UpdatableTexture.ts index 2b0124893c9..9f146003780 100644 --- a/frontend/javascripts/libs/UpdatableTexture.ts +++ b/frontend/javascripts/libs/UpdatableTexture.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import { document } from "libs/window"; import _ from "lodash"; +import { TypedArray } from "oxalis/constants"; const lazyGetCanvas = _.memoize(() => { const canvas = document.createElement("canvas"); @@ -9,25 +10,42 @@ const lazyGetCanvas = _.memoize(() => { return canvas; }); -const getImageData = _.memoize((width: number, height: number): ImageData => { - const canvas = lazyGetCanvas(); - const ctx = canvas.getContext("2d"); - if (ctx == null) { - throw new Error("Could not get context for texture."); - } - const imageData = ctx.createImageData(width, height); +const getImageData = _.memoize( + ( + width: number, + height: number, + isInt: boolean, + ): { width: number; height: number; data: TypedArray } => { + const canvas = lazyGetCanvas(); + const ctx = canvas.getContext("2d"); + if (ctx == null) { + throw new Error("Could not get context for texture."); + } - // Explicitly "release" canvas. Necessary for iOS. - // See https://pqina.nl/blog/total-canvas-memory-use-exceeds-the-maximum-limit/ - canvas.width = 1; - canvas.height = 1; - ctx.clearRect(0, 0, 1, 1); + // Integer textures cannot be used with four channels, which is why + // we are creating an image-like object here which can be used + // with ThreeJS if isDataTexture = true is given. + if (isInt) { + return { width, height, data: new Uint32Array(4 * width * height) }; + } - return imageData; -}); + const imageData = ctx.createImageData(width, height); + + // Explicitly "release" canvas. Necessary for iOS. + // See https://pqina.nl/blog/total-canvas-memory-use-exceeds-the-maximum-limit/ + canvas.width = 1; + canvas.height = 1; + ctx.clearRect(0, 0, 1, 1); + + return imageData; + }, + (width: number, height: number, isInt: boolean) => `${width}_${height}_${isInt}`, +); class UpdatableTexture extends THREE.Texture { - isUpdatableTexture: boolean; + isUpdatableTexture: boolean = true; + // Needs to be set to true for integer textures: + isDataTexture: boolean = false; renderer!: THREE.WebGLRenderer; gl: any; utils!: THREE.WebGLUtils; @@ -47,7 +65,7 @@ class UpdatableTexture extends THREE.Texture { anisotropy?: number, encoding?: THREE.TextureEncoding, ) { - const imageData = getImageData(width, height); + const imageData = getImageData(width, height, type === THREE.UnsignedIntType); super( // @ts-ignore @@ -69,7 +87,6 @@ class UpdatableTexture extends THREE.Texture { this.flipY = false; this.unpackAlignment = 1; this.needsUpdate = true; - this.isUpdatableTexture = true; } setRenderer(renderer: THREE.WebGLRenderer) { @@ -110,7 +127,13 @@ class UpdatableTexture extends THREE.Texture { return this.renderer.properties.get(this).__webglTexture != null; } - update(src: Float32Array | Uint8Array, x: number, y: number, width: number, height: number) { + update( + src: Float32Array | Uint8Array | Uint32Array, + x: number, + y: number, + width: number, + height: number, + ) { if (!this.isInitialized()) { this.renderer.initTexture(this); } diff --git a/frontend/javascripts/libs/error_handling.ts b/frontend/javascripts/libs/error_handling.ts index 7bcfeba593c..675cad99123 100644 --- a/frontend/javascripts/libs/error_handling.ts +++ b/frontend/javascripts/libs/error_handling.ts @@ -201,12 +201,12 @@ class ErrorHandling { }); } - assert = ( + assert( bool: boolean, message: string, assertionContext?: Record, dontThrowError: boolean = false, - ) => { + ): asserts bool is true { if (bool) { return; } @@ -224,9 +224,13 @@ class ErrorHandling { console.error(error); this.airbrake.notify(error); } - }; + } - assertExists(variable: any, message: string, assertionContext?: Record) { + assertExists( + variable: T | null, + message: string, + assertionContext?: Record, + ): asserts variable is NonNullable { if (variable != null) { return; } @@ -264,4 +268,6 @@ class ErrorHandling { } } -export default new ErrorHandling(); +const errorHandling: ErrorHandling = new ErrorHandling(); + +export default errorHandling; diff --git a/frontend/javascripts/libs/input.ts b/frontend/javascripts/libs/input.ts index bbb26990c23..190063a20a8 100644 --- a/frontend/javascripts/libs/input.ts +++ b/frontend/javascripts/libs/input.ts @@ -614,9 +614,7 @@ export class InputMouse { // @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'. const boundingRect = document.getElementById(this.targetId).getBoundingClientRect(); return _.extend({}, boundingRect, { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'scrollX' does not exist on type '(Window... Remove this comment to see the full error message left: boundingRect.left + window.scrollX, - // @ts-expect-error ts-migrate(2339) FIXME: Property 'scrollY' does not exist on type '(Window... Remove this comment to see the full error message top: boundingRect.top + window.scrollY, }); } diff --git a/frontend/javascripts/libs/mjs.ts b/frontend/javascripts/libs/mjs.ts index 1403ef445bd..34196acca3d 100644 --- a/frontend/javascripts/libs/mjs.ts +++ b/frontend/javascripts/libs/mjs.ts @@ -3,7 +3,7 @@ // https://github.com/imbcmdth/mjs/blob/master/index.js // for all functions in M4x4, V2 and V3. import _ from "lodash"; -import type { Vector2, Vector3 } from "oxalis/constants"; +import type { Vector2, Vector3, Vector4 } from "oxalis/constants"; import { chunk3 } from "oxalis/model/helpers/chunk"; import mjs from "mjs"; @@ -188,6 +188,9 @@ const V2 = { equals(vec1: Vector2, vec2: Vector2): boolean { return vec1[0] === vec2[0] && vec1[1] === vec2[1]; }, + isEqual(a: Vector2, b: Vector2) { + return a[0] === b[0] && a[1] === b[1]; + }, }; const _tmpVec: Vector3 = [0, 0, 0]; @@ -291,6 +294,15 @@ const V3 = { res[index] = Math.floor(res[index] / resolution[index]) * resolution[index]; return res; }, + isEqual(a: Vector3, b: Vector3) { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; + }, +}; + +const V4 = { + isEqual(a: Vector4, b: Vector4) { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; + }, }; -export { M4x4, V2, V3 }; +export { M4x4, V2, V3, V4 }; diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 8d41bfc6743..2f8c57c7266 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -37,6 +37,10 @@ export function map3(fn: (arg0: A, arg1: 0 | 1 | 2) => B, tuple: [A, A, A] const [x, y, z] = tuple; return [fn(x, 0), fn(y, 1), fn(z, 2)]; } +export function take3(tuple: [A, A, A, A]): [A, A, A] { + return [tuple[0], tuple[1], tuple[2]]; +} + export function map4( fn: (arg0: A, arg1: 0 | 1 | 2 | 3) => B, tuple: [A, A, A, A], @@ -191,6 +195,43 @@ export function hexToRgb(hex: string): Vector3 { const b = bigint & 255; return [r, g, b]; } +/** + * Converts an HSL color value to RGB. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes h, s, and l are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + * + * Taken from: + * https://stackoverflow.com/a/9493060 + */ +export function hslaToRgba(hsla: Vector4): Vector4 { + const [h, s, l, a] = hsla; + let r; + let g; + let b; + + if (s === 0) { + // eslint-disable-next-line no-multi-assign + r = g = b = l; // achromatic + } else { + const hue2rgb = function hue2rgb(p: number, q: number, t: number) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255), Math.round(a * 255)]; +} export function colorObjectToRGBArray({ r, g, b }: ColorObject): Vector3 { return [r, g, b]; } @@ -469,7 +510,7 @@ export function busyWaitDevHelper(time: number) { } } } -export function animationFrame(maxTimeout?: number): Promise { +export function animationFrame(maxTimeout?: number): Promise { const rafPromise: Promise> = new Promise( (resolve) => { window.requestAnimationFrame(resolve); @@ -859,7 +900,6 @@ export function convertBufferToImage( } export function getIsInIframe() { try { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'self' does not exist on type '(Window & ... Remove this comment to see the full error message return window.self !== window.top; } catch (e) { return true; @@ -870,12 +910,9 @@ export function getWindowBounds(): [number, number] { let width = 0; let height = 0; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'innerWidth' does not exist on type '(Win... Remove this comment to see the full error message if (typeof window.innerWidth === "number") { // Non-IE - // @ts-expect-error ts-migrate(2339) FIXME: Property 'innerWidth' does not exist on type '(Win... Remove this comment to see the full error message width = window.innerWidth; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'innerHeight' does not exist on type '(Wi... Remove this comment to see the full error message height = window.innerHeight; } else if ( // @ts-expect-error ts-migrate(2339) FIXME: Property 'documentElement' does not exist on type ... Remove this comment to see the full error message diff --git a/frontend/javascripts/libs/window.ts b/frontend/javascripts/libs/window.ts index 7ef713f560f..68a134086ef 100644 --- a/frontend/javascripts/libs/window.ts +++ b/frontend/javascripts/libs/window.ts @@ -59,12 +59,13 @@ let performanceCounterForMocking = 0; const _window = typeof window === "undefined" - ? { + ? ({ alert: console.log.bind(console), app: null, location: dummyLocation, - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'resolve' implicitly has an 'any' type. - requestAnimationFrame: (resolve) => resolve(), + requestAnimationFrame: (resolver: Function) => { + setTimeout(resolver, 16); + }, document, navigator: { onLine: true, @@ -75,7 +76,7 @@ const _window = removeEventListener, open: (_url: string) => {}, performance: { now: () => ++performanceCounterForMocking }, - } + } as typeof window) : window; export default _window; diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx index 0a35cd6bb1d..40b5ea2e05d 100644 --- a/frontend/javascripts/messages.tsx +++ b/frontend/javascripts/messages.tsx @@ -421,8 +421,6 @@ instead. Only enable this option if you understand its effect. All layers will n "users.change_email_confirmation": "The email has been changed", "mapping.too_big": "The mapping contains too many values, currently only up to 2^24 values are supported.", - "mapping.too_few_textures": - "Not enough textures available to support mappings. Mappings are disabled.", "mapping.unsupported_layer": "Mappings can only be enabled for segmentation layers.", "project.report.failed_to_refresh": "The project report page could not be refreshed. Please try to reload the page.", diff --git a/frontend/javascripts/oxalis/api/api_latest.ts b/frontend/javascripts/oxalis/api/api_latest.ts index e07b799a1ea..c2762f3df2c 100644 --- a/frontend/javascripts/oxalis/api/api_latest.ts +++ b/frontend/javascripts/oxalis/api/api_latest.ts @@ -41,6 +41,7 @@ import { getMappingsForDatasetLayer, requestTask, downsampleSegmentation, + sendAnalyticsEvent, } from "admin/admin_rest_api"; import { findTreeByNodeId, @@ -1145,10 +1146,6 @@ class DataApi { showLoadingIndicator?: boolean; } = {}, ) { - if (!Model.isMappingSupported) { - throw new Error(messages["mapping.too_few_textures"]); - } - const layer = this.model.getLayerByName(layerName); if (!layer.isSegmentation) { @@ -1156,6 +1153,11 @@ class DataApi { } const { colors: mappingColors, hideUnmappedIds, showLoadingIndicator } = options; + if (mappingColors != null) { + // Consider removing custom color support if this event is rarely used + // (see `mappingColors` handling in mapping_saga.ts) + sendAnalyticsEvent("setMapping called with custom colors"); + } const mappingProperties = { mapping: _.clone(mapping), // Object.keys is sorted for numerical keys according to the spec: diff --git a/frontend/javascripts/oxalis/api/api_v2.ts b/frontend/javascripts/oxalis/api/api_v2.ts index 3df147616d4..c40e2342d0b 100644 --- a/frontend/javascripts/oxalis/api/api_v2.ts +++ b/frontend/javascripts/oxalis/api/api_v2.ts @@ -558,10 +558,6 @@ class DataApi { * api.setMapping("segmentation", mapping); */ setMapping(layerName: string, mapping: Mapping) { - if (!Model.isMappingSupported) { - throw new Error(messages["mapping.too_few_textures"]); - } - const segmentationLayer = this.model.getLayerByName(layerName); const segmentationLayerName = segmentationLayer != null ? segmentationLayer.name : null; diff --git a/frontend/javascripts/oxalis/controller.tsx b/frontend/javascripts/oxalis/controller.tsx index c44da70e37b..072d60a037e 100644 --- a/frontend/javascripts/oxalis/controller.tsx +++ b/frontend/javascripts/oxalis/controller.tsx @@ -218,7 +218,6 @@ class Controller extends React.PureComponent { isWebGlSupported() { return ( - // @ts-expect-error ts-migrate(2339) FIXME: Property 'WebGLRenderingContext' does not exist on... Remove this comment to see the full error message window.WebGLRenderingContext && document.createElement("canvas").getContext("experimental-webgl") ); diff --git a/frontend/javascripts/oxalis/controller/scene_controller.ts b/frontend/javascripts/oxalis/controller/scene_controller.ts index fc6c74c6f77..0c34490a033 100644 --- a/frontend/javascripts/oxalis/controller/scene_controller.ts +++ b/frontend/javascripts/oxalis/controller/scene_controller.ts @@ -17,7 +17,6 @@ import { getRenderer } from "oxalis/controller/renderer"; import { getSomeTracing } from "oxalis/model/accessors/tracing_accessor"; import { getSkeletonTracing } from "oxalis/model/accessors/skeletontracing_accessor"; import { getVoxelPerNM } from "oxalis/model/scaleinfo"; -import { jsConvertCellIdToHSLA } from "oxalis/shaders/segmentation.glsl"; import { listenToStoreProperty } from "oxalis/model/helpers/listener_helpers"; import { sceneControllerReadyAction } from "oxalis/model/actions/actions"; import ArbitraryPlane from "oxalis/geometries/arbitrary_plane"; @@ -38,7 +37,8 @@ import constants, { TDViewDisplayModeEnum, } from "oxalis/constants"; import window from "libs/window"; -import { setSceneController } from "./scene_controller_provider"; +import { setSceneController } from "oxalis/controller/scene_controller_provider"; +import { getSegmentColorAsHSL } from "oxalis/model/accessors/volumetracing_accessor"; const CUBE_COLOR = 0x999999; @@ -192,14 +192,20 @@ class SceneController { return this.isosurfacesGroupsPerSegmentationId[cellId]; } - constructSceneMesh(cellId: number, geometry: THREE.BufferGeometry, passive: boolean) { - const [hue] = jsConvertCellIdToHSLA(cellId); - const color = new THREE.Color().setHSL(hue, 0.75, 0.05); + getColorObjectForSegment(cellId: number) { + const [hue, saturation, light] = getSegmentColorAsHSL(Store.getState(), cellId); + const color = new THREE.Color().setHSL(hue, 0.75 * saturation, light / 10); + return color; + } + + constructIsosurfaceMesh(cellId: number, geometry: THREE.BufferGeometry, passive: boolean) { + const color = this.getColorObjectForSegment(cellId); const meshMaterial = new THREE.MeshLambertMaterial({ color, }); meshMaterial.side = THREE.FrontSide; meshMaterial.transparent = true; + const mesh = new THREE.Mesh(geometry, meshMaterial); mesh.castShadow = true; mesh.receiveShadow = true; @@ -235,7 +241,7 @@ class SceneController { const meshNumber = _.size(this.stlMeshes); - const mesh = this.constructSceneMesh(meshNumber, geometry, false); + const mesh = this.constructIsosurfaceMesh(meshNumber, geometry, false); this.meshesRootGroup.add(mesh); this.stlMeshes[id] = mesh; this.updateMeshPostion(id, position); @@ -267,8 +273,6 @@ class SceneController { segmentationId: number, passive: boolean = false, ): void { - const mesh = this.constructSceneMesh(segmentationId, geometry, passive); - if (this.isosurfacesGroupsPerSegmentationId[segmentationId] == null) { const newGroup = new THREE.Group(); this.isosurfacesGroupsPerSegmentationId[segmentationId] = newGroup; @@ -278,6 +282,7 @@ class SceneController { // @ts-ignore newGroup.passive = passive; } + const mesh = this.constructIsosurfaceMesh(segmentationId, geometry, passive); this.isosurfacesGroupsPerSegmentationId[segmentationId].add(mesh); } @@ -317,6 +322,17 @@ class SceneController { this.isosurfacesGroupsPerSegmentationId[id].visible = visibility; } + setIsosurfaceColor(id: number): void { + const color = this.getColorObjectForSegment(id); + const group = this.isosurfacesGroupsPerSegmentationId[id]; + if (group) { + for (const child of group.children) { + // @ts-ignore + child.material.color = color; + } + } + } + updateMeshPostion(id: string, position: Vector3): void { const [x, y, z] = position; const mesh = this.stlMeshes[id]; diff --git a/frontend/javascripts/oxalis/controller/url_manager.ts b/frontend/javascripts/oxalis/controller/url_manager.ts index a46d0928d2f..10b0272dca4 100644 --- a/frontend/javascripts/oxalis/controller/url_manager.ts +++ b/frontend/javascripts/oxalis/controller/url_manager.ts @@ -200,7 +200,6 @@ class UrlManager { startUrlUpdater(): void { Store.subscribe(() => this.update()); - // @ts-expect-error ts-migrate(2339) FIXME: Property 'onhashchange' does not exist on type '(W... Remove this comment to see the full error message window.onhashchange = () => this.onHashChange(); } diff --git a/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts b/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts index 26255377ee5..fe70c17b4f9 100644 --- a/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts +++ b/frontend/javascripts/oxalis/geometries/materials/edge_shader.ts @@ -20,11 +20,9 @@ class EdgeShader { setupUniforms(treeColorTexture: THREE.DataTexture): void { this.uniforms = { activeTreeId: { - type: "f", value: NaN, }, treeColors: { - type: "t", value: treeColorTexture, }, }; diff --git a/frontend/javascripts/oxalis/geometries/materials/node_shader.ts b/frontend/javascripts/oxalis/geometries/materials/node_shader.ts index 945c40d15e3..8433b329f16 100644 --- a/frontend/javascripts/oxalis/geometries/materials/node_shader.ts +++ b/frontend/javascripts/oxalis/geometries/materials/node_shader.ts @@ -37,7 +37,6 @@ class NodeShader { const state = Store.getState(); this.uniforms = { planeZoomFactor: { - type: "f", // The flycam zoom is typically decomposed into an x- and y-factor // which respects the aspect ratio. However, this value is merely used // for selecting an appropriate node size (gl_PointSize). The resulting points @@ -45,47 +44,36 @@ class NodeShader { value: getZoomValue(state.flycam), }, datasetScale: { - type: "f", value: getBaseVoxel(state.dataset.dataSource.scale), }, overrideParticleSize: { - type: "f", value: state.userConfiguration.particleSize, }, viewportScale: { - type: "f", value: 1, }, overrideNodeRadius: { - type: "i", value: true, }, activeTreeId: { - type: "f", value: NaN, }, activeNodeId: { - type: "f", value: NaN, }, treeColors: { - type: "t", value: treeColorTexture, }, isPicking: { - type: "i", value: 0, }, isTouch: { - type: "i", value: 0, }, highlightCommentedNodes: { - type: "f", value: state.userConfiguration.highlightCommentedNodes, }, viewMode: { - type: "f", value: 0, }, }; diff --git a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts index ee2d405f710..e6acca635c6 100644 --- a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts +++ b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory.ts @@ -24,6 +24,7 @@ import { getUnrenderableLayerInfosForCurrentZoom, getSegmentationLayerWithMappingSupport, getMappingInfoForSupportedLayer, + getVisibleSegmentationLayer, } from "oxalis/model/accessors/dataset_accessor"; import { getRequestLogZoomStep, getZoomValue } from "oxalis/model/accessors/flycam_accessor"; import { listenToStoreProperty } from "oxalis/model/helpers/listener_helpers"; @@ -35,6 +36,8 @@ import app from "app"; import getMainFragmentShader from "oxalis/shaders/main_data_fragment.glsl"; import shaderEditor from "oxalis/model/helpers/shader_editor"; import type { ElementClass } from "types/api_flow_types"; +import { CuckooTable } from "oxalis/model/bucket_data_handling/cuckoo_table"; + type ShaderMaterialOptions = { polygonOffset?: boolean; polygonOffsetFactor?: number; @@ -44,7 +47,6 @@ const RECOMPILATION_THROTTLE_TIME = 500; export type Uniforms = Record< string, { - type: "b" | "f" | "i" | "t" | "v2" | "v3" | "v4" | "tv"; value: any; } >; @@ -90,6 +92,7 @@ class PlaneMaterialFactory { storePropertyUnsubscribers: Array<() => void> = []; leastRecentlyVisibleLayers: Array<{ name: string; isSegmentationLayer: boolean }>; oldShaderCode: string | null | undefined; + unsubscribeSeedsFn: (() => void) | null = null; constructor(planeID: OrthoView, isOrthogonal: boolean, shaderId: number) { this.planeID = planeID; @@ -107,6 +110,7 @@ class PlaneMaterialFactory { stopListening() { this.storePropertyUnsubscribers.forEach((fn) => fn()); + this.storePropertyUnsubscribers = []; } setupUniforms(): void { @@ -115,107 +119,82 @@ class PlaneMaterialFactory { ); this.uniforms = { sphericalCapRadius: { - type: "f", value: 140, }, globalPosition: { - type: "v3", value: new THREE.Vector3(0, 0, 0), }, anchorPoint: { - type: "v4", value: new THREE.Vector3(0, 0, 0), }, zoomStep: { - type: "f", value: 1, }, zoomValue: { - type: "f", value: 1, }, useBilinearFiltering: { - type: "b", value: true, }, isMappingEnabled: { - type: "b", value: false, }, mappingSize: { - type: "f", value: 0, }, hideUnmappedIds: { - type: "b", value: false, }, globalMousePosition: { - type: "v3", value: new THREE.Vector3(0, 0, 0), }, brushSizeInPixel: { - type: "f", value: 0, }, segmentationPatternOpacity: { - type: "f", value: 40, }, isMouseInActiveViewport: { - type: "b", value: false, }, isMouseInCanvas: { - type: "b", value: false, }, showBrush: { - type: "b", value: false, }, viewMode: { - type: "f", value: 0, }, planeID: { - type: "f", value: OrthoViewValues.indexOf(this.planeID), }, bboxMin: { - type: "v3", value: new THREE.Vector3(0, 0, 0), }, bboxMax: { - type: "v3", value: new THREE.Vector3(0, 0, 0), }, renderBucketIndices: { - type: "b", value: false, }, addressSpaceDimensions: { - type: "v3", value: new THREE.Vector3(...addressSpaceDimensions), }, // The hovered segment id is always stored as a 64-bit (8 byte) // value which is why it is spread over two uniforms, // named as `-High` and `-Low`. hoveredSegmentIdHigh: { - type: "v4", value: new THREE.Vector4(0, 0, 0, 0), }, hoveredSegmentIdLow: { - type: "v4", value: new THREE.Vector4(0, 0, 0, 0), }, // The same is done for the active cell id. activeCellIdHigh: { - type: "v4", value: new THREE.Vector4(0, 0, 0, 0), }, activeCellIdLow: { - type: "v4", value: new THREE.Vector4(0, 0, 0, 0), }, }; @@ -223,41 +202,33 @@ class PlaneMaterialFactory { for (const dataLayer of Model.getAllLayers()) { const layerName = sanitizeName(dataLayer.name); this.uniforms[`${layerName}_maxZoomStep`] = { - type: "f", value: dataLayer.cube.resolutionInfo.getHighestResolutionIndex(), }; this.uniforms[`${layerName}_alpha`] = { - type: "f", value: 1, }; this.uniforms[`${layerName}_gammaCorrectionValue`] = { - type: "f", value: 1, }; // If the `_unrenderable` uniform is true, the layer // cannot (and should not) be rendered in the // current mag. this.uniforms[`${layerName}_unrenderable`] = { - type: "f", value: 0, }; } for (const name of getSanitizedColorLayerNames()) { this.uniforms[`${name}_color`] = { - type: "v3", value: DEFAULT_COLOR, }; this.uniforms[`${name}_min`] = { - type: "f", value: 0.0, }; this.uniforms[`${name}_max`] = { - type: "f", value: 1.0, }; this.uniforms[`${name}_is_inverted`] = { - type: "f", value: 0, }; } @@ -274,40 +245,75 @@ class PlaneMaterialFactory { const [lookUpTexture, ...dataTextures] = dataLayer.layerRenderingManager.getDataTextures(); const layerName = sanitizeName(name); this.uniforms[`${layerName}_textures`] = { - type: "tv", value: dataTextures, }; this.uniforms[`${layerName}_data_texture_width`] = { - type: "f", value: dataLayer.layerRenderingManager.textureWidth, }; this.uniforms[`${layerName}_lookup_texture`] = { - type: "t", value: lookUpTexture, }; } - this.attachSegmentationTextures(); + this.attachSegmentationMappingTextures(); + this.attachSegmentationColorTexture(); } - attachSegmentationTextures(): void { + attachSegmentationMappingTextures(): void { const segmentationLayer = Model.getSegmentationLayerWithMappingSupport(); - const [mappingTexture, mappingLookupTexture, mappingColorTexture] = - Model.isMappingSupported && segmentationLayer != null && segmentationLayer.mappings != null + const [mappingTexture, mappingLookupTexture] = + segmentationLayer != null && segmentationLayer.mappings != null ? segmentationLayer.mappings.getMappingTextures() // It's important to set up the uniforms (even when they are null), since later : // additions to `this.uniforms` won't be properly attached otherwise. [null, null, null]; + this.uniforms.segmentation_mapping_texture = { - type: "t", value: mappingTexture, }; this.uniforms.segmentation_mapping_lookup_texture = { - type: "t", value: mappingLookupTexture, }; - this.uniforms.segmentation_mapping_color_texture = { - type: "t", - value: mappingColorTexture, + } + + attachSegmentationColorTexture(): void { + const segmentationLayer = Model.getVisibleSegmentationLayer(); + if (segmentationLayer == null) { + this.uniforms.seed0 = { value: 0 }; + this.uniforms.seed1 = { value: 0 }; + this.uniforms.seed2 = { value: 0 }; + + this.uniforms.CUCKOO_ENTRY_CAPACITY = { value: 0 }; + this.uniforms.CUCKOO_ELEMENTS_PER_ENTRY = { value: 0 }; + this.uniforms.CUCKOO_ELEMENTS_PER_TEXEL = { value: 0 }; + this.uniforms.CUCKOO_TWIDTH = { value: 0 }; + this.uniforms.custom_color_texture = { value: CuckooTable.getNullTexture() }; + return; + } + const cuckoo = segmentationLayer.layerRenderingManager.getCustomColorCuckooTable(); + const customColorTexture = cuckoo.getTexture(); + + if (this.unsubscribeSeedsFn != null) { + this.unsubscribeSeedsFn(); + } + this.unsubscribeSeedsFn = cuckoo.subscribeToSeeds((seeds: number[]) => { + seeds.forEach((seed, idx) => { + this.uniforms[`seed${idx}`] = { + value: seed, + }; + }); + }); + const { + CUCKOO_ENTRY_CAPACITY, + CUCKOO_ELEMENTS_PER_ENTRY, + CUCKOO_ELEMENTS_PER_TEXEL, + CUCKOO_TWIDTH, + } = cuckoo.getUniformValues(); + this.uniforms.CUCKOO_ENTRY_CAPACITY = { value: CUCKOO_ENTRY_CAPACITY }; + this.uniforms.CUCKOO_ELEMENTS_PER_ENTRY = { value: CUCKOO_ELEMENTS_PER_ENTRY }; + this.uniforms.CUCKOO_ELEMENTS_PER_TEXEL = { value: CUCKOO_ELEMENTS_PER_TEXEL }; + this.uniforms.CUCKOO_TWIDTH = { value: CUCKOO_TWIDTH }; + this.uniforms.custom_color_texture = { + value: customColorTexture, }; } @@ -504,7 +510,15 @@ class PlaneMaterialFactory { listenToStoreProperty( (storeState) => getSegmentationLayerWithMappingSupport(storeState), (_segmentationLayer) => { - this.attachSegmentationTextures(); + this.attachSegmentationMappingTextures(); + }, + ), + ); + this.storePropertyUnsubscribers.push( + listenToStoreProperty( + (storeState) => getVisibleSegmentationLayer(storeState), + (_segmentationLayer) => { + this.attachSegmentationColorTexture(); }, ), ); @@ -729,7 +743,6 @@ class PlaneMaterialFactory { colorLayerNames, segmentationLayerNames, packingDegreeLookup, - isMappingSupported: Model.isMappingSupported, // Todo: this is not computed per layer. See #4018 dataTextureCountPerLayer: Model.maximumTextureCountForLayer, resolutions: getResolutions(dataset), diff --git a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory_helpers.ts b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory_helpers.ts index e8c10153e74..239905424c8 100644 --- a/frontend/javascripts/oxalis/geometries/materials/plane_material_factory_helpers.ts +++ b/frontend/javascripts/oxalis/geometries/materials/plane_material_factory_helpers.ts @@ -1,22 +1,37 @@ import * as THREE from "three"; import UpdatableTexture from "libs/UpdatableTexture"; -export const channelCountToFormat = { - "1": THREE.RedFormat, - "2": THREE.RGFormat, - // ThreeJS does not support RGB textures, anymore, which is why we pad the data - // from RGB to RGBA before uploading the data to the GPU. - "3": THREE.RGBAFormat, - "4": THREE.RGBAFormat, -}; + +function channelCountToFormat(channelCount: number, type: THREE.TextureDataType) { + switch (channelCount) { + case 1: { + if (type === THREE.IntType) return THREE.RedIntegerFormat; + return THREE.RedFormat; + } + case 2: { + if (type === THREE.IntType) return THREE.RGIntegerFormat; + return THREE.RGFormat; + } + // ThreeJS does not support RGB textures, anymore, which is why we pad the data + // from RGB to RGBA before uploading the data to the GPU. + case 3: + case 4: { + if (type === THREE.IntType) return THREE.RGBAIntegerFormat; + return THREE.RGBAFormat; + } + default: { + throw new Error(`Unsupported channel count: ${channelCount}`); + } + } +} // This function has to be in its own file as non-resolvable cycles are created otherwise export function createUpdatableTexture( width: number, channelCount: number, type: THREE.TextureDataType, renderer: THREE.WebGLRenderer, + optFormat?: THREE.PixelFormat, ): UpdatableTexture { - // @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 format = channelCountToFormat[channelCount]; + const format = optFormat ?? channelCountToFormat(channelCount, type); if (!format) { throw new Error(`Unhandled byte count: ${channelCount}`); diff --git a/frontend/javascripts/oxalis/merger_mode.ts b/frontend/javascripts/oxalis/merger_mode.ts index c643d751753..9b94e20056d 100644 --- a/frontend/javascripts/oxalis/merger_mode.ts +++ b/frontend/javascripts/oxalis/merger_mode.ts @@ -1,6 +1,5 @@ -import { Modal } from "antd"; import _ from "lodash"; -import type { NodeWithTreeId } from "oxalis/model/sagas/update_actions"; +import type { DeleteNodeUpdateAction, NodeWithTreeId } from "oxalis/model/sagas/update_actions"; import type { TreeMap, SkeletonTracing, OxalisState } from "oxalis/store"; import type { Vector3 } from "oxalis/constants"; import { cachedDiffTrees } from "oxalis/model/sagas/skeletontracing_saga"; @@ -14,8 +13,8 @@ import { Action } from "oxalis/model/actions/actions"; import { CreateNodeAction } from "./model/actions/skeletontracing_actions"; type MergerModeState = { - treeColors: Record; - colorMapping: Record; + treeIdToRepresentativeSegmentId: Record; + idMapping: Record; nodesPerSegment: Record; nodes: Array; // A properly initialized merger mode should always @@ -31,28 +30,32 @@ const unregisterKeyHandlers: UnregisterHandler[] = []; const unsubscribeFunctions: Array<() => void> = []; let isCodeActive = false; -function mapSegmentColorToTree(segId: number, treeId: number, mergerModeState: MergerModeState) { - // add segment to color mapping - const color = getTreeColor(treeId, mergerModeState); - mergerModeState.colorMapping[segId] = color; +function mapSegmentToRepresentative( + segId: number, + treeId: number, + mergerModeState: MergerModeState, +) { + const representative = getRepresentativeForTree(treeId, segId, mergerModeState); + mergerModeState.idMapping[segId] = representative; } -function getTreeColor(treeId: number, mergerModeState: MergerModeState) { - const { treeColors } = mergerModeState; - let color = treeColors[treeId]; +function getRepresentativeForTree(treeId: number, segId: number, mergerModeState: MergerModeState) { + const { treeIdToRepresentativeSegmentId } = mergerModeState; + let representative = treeIdToRepresentativeSegmentId[treeId]; - // Generate a new color if tree was never seen before - if (color == null) { - color = Math.ceil(127 * Math.random()); - treeColors[treeId] = color; + // Use the passed segment id as a representative, if the tree was never seen before + if (representative == null) { + representative = segId; + treeIdToRepresentativeSegmentId[treeId] = representative; } - return color; + return representative; } -function deleteColorMappingOfSegment(segId: number, mergerModeState: MergerModeState) { +function deleteIdMappingOfSegment(segId: number, treeId: number, mergerModeState: MergerModeState) { // Remove segment from color mapping - delete mergerModeState.colorMapping[segId]; + delete mergerModeState.idMapping[segId]; + delete mergerModeState.treeIdToRepresentativeSegmentId[treeId]; } /* This function is used to increment the reference count / @@ -119,7 +122,7 @@ async function createNodeOverwrite( if (!segmentId) { api.utils.showToast("warning", messages["tracing.merger_mode_node_outside_segment"]); } else { - await call(action); + call(action); // Center the created cell manually, as somehow without this call the previous node would be centered. if (Store.getState().userConfiguration.centerNewNode) { @@ -137,7 +140,7 @@ async function onCreateNode( position: Vector3, updateMapping: boolean = true, ) { - const { colorMapping, segmentationLayerName, nodeSegmentMap } = mergerModeState; + const { idMapping, segmentationLayerName, nodeSegmentMap } = mergerModeState; if (segmentationLayerName == null) { return; @@ -156,11 +159,11 @@ async function onCreateNode( nodeSegmentMap[nodeId] = segmentId; // Count references increaseNodesOfSegment(segmentId, mergerModeState); - mapSegmentColorToTree(segmentId, treeId, mergerModeState); + mapSegmentToRepresentative(segmentId, treeId, mergerModeState); if (updateMapping) { // Update mapping - await api.data.setMapping(segmentationLayerName, colorMapping); + api.data.setMapping(segmentationLayerName, idMapping); } } @@ -169,7 +172,7 @@ async function onCreateNode( */ async function onDeleteNode( mergerModeState: MergerModeState, - nodeId: number, + nodeWithTreeId: DeleteNodeUpdateAction["value"], updateMapping: boolean = true, ) { const { segmentationLayerName } = mergerModeState; @@ -178,15 +181,15 @@ async function onDeleteNode( return; } - const segmentId = mergerModeState.nodeSegmentMap[nodeId]; + const segmentId = mergerModeState.nodeSegmentMap[nodeWithTreeId.nodeId]; const numberOfNodesMappedToSegment = decreaseNodesOfSegment(segmentId, mergerModeState); if (numberOfNodesMappedToSegment === 0) { // Reset color of all segments that were mapped to this tree - deleteColorMappingOfSegment(segmentId, mergerModeState); + deleteIdMappingOfSegment(segmentId, nodeWithTreeId.treeId, mergerModeState); if (updateMapping) { - await api.data.setMapping(segmentationLayerName, mergerModeState.colorMapping); + api.data.setMapping(segmentationLayerName, mergerModeState.idMapping); } } } @@ -205,7 +208,7 @@ async function onUpdateNode(mergerModeState: MergerModeState, node: NodeWithTree // If the segment of the node changed, it is like the node got deleted and a copy got created somewhere else. // Thus we use the onNodeDelete and onNodeCreate method to update the mapping. if (nodeSegmentMap[id] != null) { - await onDeleteNode(mergerModeState, id, false); + await onDeleteNode(mergerModeState, { nodeId: id, treeId }, false); } if (segmentId != null && segmentId > 0) { @@ -215,7 +218,7 @@ async function onUpdateNode(mergerModeState: MergerModeState, node: NodeWithTree delete nodeSegmentMap[id]; } - api.data.setMapping(segmentationLayerName, mergerModeState.colorMapping); + api.data.setMapping(segmentationLayerName, mergerModeState.idMapping); } } @@ -231,7 +234,7 @@ function updateState(mergerModeState: MergerModeState, skeletonTracing: Skeleton } case "deleteNode": - onDeleteNode(mergerModeState, action.value.nodeId); + onDeleteNode(mergerModeState, action.value); break; case "updateNode": @@ -269,51 +272,13 @@ function changeOpacity(mergerModeState: MergerModeState) { api.data.setConfiguration("layers", copyOfLayerSettings); } -function shuffleColorOfCurrentTree(mergerModeState: MergerModeState) { - const { treeColors, colorMapping, segmentationLayerName } = mergerModeState; - - if (segmentationLayerName == null) { - return; - } - - const setNewColorOfCurrentActiveTree = () => { - const activeTreeId = api.tracing.getActiveTreeId(); - - if (activeTreeId == null) { - Modal.info({ - title: "Could not find an active tree.", - }); - return; - } - - const oldColor = getTreeColor(activeTreeId, mergerModeState); - // Reset the color of the active tree - treeColors[activeTreeId] = undefined; - // Applies the change of the color to all connected segments - Object.keys(colorMapping).forEach((key) => { - if (colorMapping[+key] === oldColor) { - colorMapping[+key] = getTreeColor(activeTreeId, mergerModeState); - } - }); - // Update the segmentation - api.data.setMapping(segmentationLayerName, colorMapping); - }; - - Modal.confirm({ - title: "Do you want to set a new Color?", - onOk: setNewColorOfCurrentActiveTree, - - onCancel() {}, - }); -} - async function mergeSegmentsOfAlreadyExistingTrees( // eslint-disable-next-line @typescript-eslint/default-param-last index = 0, mergerModeState: MergerModeState, onProgressUpdate: (arg0: number) => void, ) { - const { nodes, segmentationLayerName, nodeSegmentMap, colorMapping } = mergerModeState; + const { nodes, segmentationLayerName, nodeSegmentMap, idMapping } = mergerModeState; const numbOfNodes = nodes.length; if (index >= numbOfNodes) { @@ -350,7 +315,7 @@ async function mergeSegmentsOfAlreadyExistingTrees( nodeSegmentMap[node.id] = segmentId; // Add to agglomerate increaseNodesOfSegment(segmentId, mergerModeState); - mapSegmentColorToTree(segmentId, treeId, mergerModeState); + mapSegmentToRepresentative(segmentId, treeId, mergerModeState); } }; @@ -367,7 +332,7 @@ async function mergeSegmentsOfAlreadyExistingTrees( await Promise.all(nodesMappedPromises); } - api.data.setMapping(segmentationLayerName, colorMapping); + api.data.setMapping(segmentationLayerName, idMapping); } function resetState(mergerModeState: Partial = {}) { @@ -375,8 +340,8 @@ function resetState(mergerModeState: Partial = {}) { const visibleLayer = getVisibleSegmentationLayer(Store.getState()); const segmentationLayerName = visibleLayer != null ? visibleLayer.name : null; const defaults = { - treeColors: {}, - colorMapping: {}, + treeIdToRepresentativeSegmentId: {}, + idMapping: {}, nodesPerSegment: {}, nodes: getAllNodesWithTreeId(), segmentationLayerName, @@ -424,12 +389,7 @@ export async function enableMergerMode( createNodeOverwrite(store, next, originalAction as CreateNodeAction, mergerModeState), ), ); - // Register the additional key handlers - unregisterKeyHandlers.push( - api.utils.registerKeyHandler("8", () => { - shuffleColorOfCurrentTree(mergerModeState); - }), - ); + // Register the additional key handler unregisterKeyHandlers.push( api.utils.registerKeyHandler("9", () => { changeOpacity(mergerModeState); @@ -439,6 +399,7 @@ export async function enableMergerMode( await mergeSegmentsOfAlreadyExistingTrees(0, mergerModeState, onProgressUpdate); return mergerModeState.segmentationLayerName; } + export function disableMergerMode(segmentationLayerName: string | null | undefined) { if (!isCodeActive) { return; diff --git a/frontend/javascripts/oxalis/model.ts b/frontend/javascripts/oxalis/model.ts index 50076e26664..c0a8de90768 100644 --- a/frontend/javascripts/oxalis/model.ts +++ b/frontend/javascripts/oxalis/model.ts @@ -28,7 +28,6 @@ import { initialize } from "./model_initialization"; export class OxalisModel { // @ts-expect-error ts-migrate(2564) FIXME: Property 'dataLayers' has no initializer and is no... Remove this comment to see the full error message dataLayers: Record; - isMappingSupported: boolean = true; maximumTextureCountForLayer: number = 0; async fetch( @@ -47,15 +46,13 @@ export class OxalisModel { if (initializationInformation) { // Only executed on initial fetch - const { dataLayers, isMappingSupported, maximumTextureCountForLayer } = - initializationInformation; + const { dataLayers, maximumTextureCountForLayer } = initializationInformation; if (this.dataLayers != null) { _.values(this.dataLayers).forEach((layer) => layer.destroy()); } this.dataLayers = dataLayers; - this.isMappingSupported = isMappingSupported; this.maximumTextureCountForLayer = maximumTextureCountForLayer; } } catch (error) { diff --git a/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts b/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts index ad2aa727acf..fab53a63068 100644 --- a/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts +++ b/frontend/javascripts/oxalis/model/accessors/dataset_accessor.ts @@ -715,6 +715,7 @@ export function getVisibleSegmentationLayers(state: OxalisState): Array { return tracing.volumes; @@ -308,15 +310,8 @@ export function getNameOfRequestedOrVisibleSegmentationLayer( return layer != null ? layer.name : null; } -export function getSegmentsForLayer( - state: OxalisState, - layerName: string | null | undefined, -): SegmentMap | null | undefined { - const layer = getRequestedOrVisibleSegmentationLayer(state, layerName); - - if (layer == null) { - return null; - } +export function getSegmentsForLayer(state: OxalisState, layerName: string): SegmentMap { + const layer = getSegmentationLayerByName(state.dataset, layerName); if (layer.tracingId != null) { return getVolumeTracingById(state.tracing, layer.tracingId).segments; @@ -498,3 +493,23 @@ export function getLabelActionFromPreviousSlice( (el) => Math.floor(adapt(el.centroid)[dim]) !== position[dim], ); } + +// Output is in [0,1] for H, S, L and A +export function getSegmentColorAsHSL(state: OxalisState, mappedId: number): Vector4 { + const visibleSegmentationLayer = getVisibleSegmentationLayer(state); + if (!visibleSegmentationLayer) { + return [1, 1, 1, 1]; + } + + const visibleSegments = getVisibleSegments(state); + if (visibleSegments) { + const segment = visibleSegments.getNullable(mappedId); + + if (segment?.color) { + const [hue, saturation, value] = jsRgb2hsl(segment.color); + return [hue, saturation, value, 1]; + } + } + + return jsConvertCellIdToHSLA(mappedId); +} diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/cuckoo_table.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/cuckoo_table.ts new file mode 100644 index 00000000000..faca2c1c773 --- /dev/null +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/cuckoo_table.ts @@ -0,0 +1,334 @@ +import * as THREE from "three"; +import UpdatableTexture from "libs/UpdatableTexture"; +import { Vector3 } from "oxalis/constants"; +import { getRenderer } from "oxalis/controller/renderer"; +import { createUpdatableTexture } from "oxalis/geometries/materials/plane_material_factory_helpers"; + +const ELEMENTS_PER_ENTRY = 4; +const TEXTURE_CHANNEL_COUNT = 4; +const DEFAULT_LOAD_FACTOR = 0.25; +const EMPTY_KEY = 2 ** 32 - 1; + +type Entry = [number, Vector3]; + +export type SeedSubscriberFn = (seeds: number[]) => void; + +let cachedNullTexture: UpdatableTexture | undefined; + +export class CuckooTable { + entryCapacity: number; + table!: Uint32Array; + seeds!: number[]; + seedSubscribers: Array = []; + _texture: UpdatableTexture; + textureWidth: number; + + constructor(textureWidth: number) { + this.textureWidth = textureWidth; + this._texture = createUpdatableTexture( + textureWidth, + TEXTURE_CHANNEL_COUNT, + THREE.UnsignedIntType, + getRenderer(), + THREE.RGBAIntegerFormat, + ); + // This is needed so that the initialization of the texture + // can be done with an Image-like object ({ width, height, data }) + // which is needed for integer textures, since createImageData results + // cannot be used for the combination THREE.UnsignedIntType + THREE.RGBAIntegerFormat. + // See the definition of texImage2D here: https://registry.khronos.org/webgl/specs/latest/2.0/ + // Note that there are two overloads of texImage2D. + this._texture.isDataTexture = true; + // The internal format has to be set manually, since ThreeJS does not + // derive this value by itself. + // See https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html + // for a reference of the internal formats. + this._texture.internalFormat = "RGBA32UI"; + + this.entryCapacity = Math.floor( + (textureWidth ** 2 * TEXTURE_CHANNEL_COUNT) / ELEMENTS_PER_ENTRY, + ); + + this.initializeTableArray(); + } + + static fromCapacity(requestedCapacity: number): CuckooTable { + const capacity = requestedCapacity / DEFAULT_LOAD_FACTOR; + const textureWidth = Math.ceil( + Math.sqrt((capacity * TEXTURE_CHANNEL_COUNT) / ELEMENTS_PER_ENTRY), + ); + return new CuckooTable(textureWidth); + } + + static getNullTexture(): UpdatableTexture { + if (cachedNullTexture) { + return cachedNullTexture; + } + cachedNullTexture = createUpdatableTexture( + 0, + TEXTURE_CHANNEL_COUNT, + THREE.UnsignedIntType, + getRenderer(), + THREE.RGBAIntegerFormat, + ); + cachedNullTexture.isDataTexture = true; + cachedNullTexture.internalFormat = "RGBA32UI"; + + return cachedNullTexture; + } + + private initializeTableArray() { + this.table = new Uint32Array(ELEMENTS_PER_ENTRY * this.entryCapacity).fill(EMPTY_KEY); + + // The chance of colliding seeds is super low which is why + // we ignore this case (a rehash would happen automatically, anyway). + // Note that it makes sense to use all 32 bits for the seeds. Otherwise, + // hash collisions are more likely to happen. + this.seeds = [ + Math.floor(2 ** 32 * Math.random()), + Math.floor(2 ** 32 * Math.random()), + Math.floor(2 ** 32 * Math.random()), + ]; + this.notifySeedListeners(); + } + + getTexture(): UpdatableTexture { + return this._texture; + } + + subscribeToSeeds(fn: SeedSubscriberFn): () => void { + this.seedSubscribers.push(fn); + this.notifySeedListeners(); + + return () => { + this.seedSubscribers = this.seedSubscribers.filter((el) => el !== fn); + }; + } + + notifySeedListeners() { + this.seedSubscribers.forEach((fn) => fn(this.seeds)); + } + + getUniformValues() { + return { + CUCKOO_ENTRY_CAPACITY: this.entryCapacity, + CUCKOO_ELEMENTS_PER_ENTRY: ELEMENTS_PER_ENTRY, + CUCKOO_ELEMENTS_PER_TEXEL: TEXTURE_CHANNEL_COUNT, + CUCKOO_TWIDTH: this.textureWidth, + }; + } + + clearAll() { + this.table.fill(EMPTY_KEY); + this._texture.update(this.table, 0, 0, this.textureWidth, this.textureWidth); + } + + set(pendingKey: number, pendingValue: Vector3, rehashAttempt: number = 0) { + if (pendingKey === EMPTY_KEY) { + throw new Error(`The key ${EMPTY_KEY} is not allowed for the CuckooTable.`); + } + let displacedEntry; + let currentAddress; + let iterationCounter = 0; + + const ITERATION_THRESHOLD = 40; + const REHASH_THRESHOLD = 100; + + if (rehashAttempt >= REHASH_THRESHOLD) { + throw new Error( + `Cannot rehash, since this is already the ${rehashAttempt}th attempt. Is the capacity exceeded?`, + ); + } + + const existingValueWithAddress = this.getWithAddress(pendingKey); + if (existingValueWithAddress) { + // The key already exists. We only have to overwrite + // the corresponding value. + const [, address] = existingValueWithAddress; + this.writeEntryAtAddress(pendingKey, pendingValue, address, rehashAttempt > 0); + return; + } + + let seedIndex = Math.floor(Math.random() * this.seeds.length); + while (iterationCounter++ < ITERATION_THRESHOLD) { + const seed = this.seeds[seedIndex]; + currentAddress = this._hashKeyToAddress(seed, pendingKey); + + // Swap pendingKey, pendingValue with what's contained in H1 + displacedEntry = this.writeEntryAtAddress( + pendingKey, + pendingValue, + currentAddress, + rehashAttempt > 0, + ); + + if (this.canDisplacedEntryBeIgnored(displacedEntry[0], pendingKey)) { + return; + } + + [pendingKey, pendingValue] = displacedEntry; + + // Pick another random seed for the next swap + seedIndex = + (seedIndex + Math.floor(Math.random() * (this.seeds.length - 1)) + 1) % this.seeds.length; + } + this.rehash(rehashAttempt + 1); + this.set(pendingKey, pendingValue, rehashAttempt + 1); + + // Since a rehash was performed, the incremental texture updates were + // skipped. Update the entire texture: + this._texture.update(this.table, 0, 0, this.textureWidth, this.textureWidth); + } + + unset(key: number) { + for (const seed of this.seeds) { + const hashedAddress = this._hashKeyToAddress(seed, key); + + const value = this.getValueAtAddress(key, hashedAddress); + if (value != null) { + this.writeEntryAtAddress( + EMPTY_KEY, + [EMPTY_KEY, EMPTY_KEY, EMPTY_KEY], + hashedAddress, + false, + ); + return; + } + } + } + + private rehash(rehashAttempt: number): void { + const oldTable = this.table; + + this.initializeTableArray(); + + for ( + let offset = 0; + offset < this.entryCapacity * ELEMENTS_PER_ENTRY; + offset += ELEMENTS_PER_ENTRY + ) { + if (oldTable[offset] === EMPTY_KEY) { + continue; + } + const key: number = oldTable[offset]; + const value: Vector3 = [oldTable[offset + 1], oldTable[offset + 2], oldTable[offset + 3]]; + this.set(key, value, rehashAttempt); + } + } + + get(key: number): Vector3 | null { + const result = this.getWithAddress(key); + return result ? result[0] : null; + } + + getWithAddress(key: number): [Vector3, number] | null { + for (const seed of this.seeds) { + const hashedAddress = this._hashKeyToAddress(seed, key); + + const value = this.getValueAtAddress(key, hashedAddress); + if (value != null) { + return [value, hashedAddress]; + } + } + return null; + } + + getEntryAtAddress(hashedAddress: number): Entry { + const offset = hashedAddress * ELEMENTS_PER_ENTRY; + return [ + this.table[offset], + [this.table[offset + 1], this.table[offset + 2], this.table[offset + 3]], + ]; + } + + private canDisplacedEntryBeIgnored(displacedKey: number, newKey: number): boolean { + return ( + // Either, the slot is empty... + // -1 is not allowed as a key + displacedKey === EMPTY_KEY || + // or the slot already refers to the key + displacedKey === newKey + ); + } + + doesAddressContainKey(key: number, hashedAddress: number): boolean { + const offset = hashedAddress * ELEMENTS_PER_ENTRY; + return this.table[offset] === key; + } + + getValueAtAddress(key: number, hashedAddress: number): Vector3 | null { + const offset = hashedAddress * ELEMENTS_PER_ENTRY; + if (this.doesAddressContainKey(key, hashedAddress)) { + return [this.table[offset + 1], this.table[offset + 2], this.table[offset + 3]]; + } else { + return null; + } + } + + private writeEntryAtAddress( + key: number, + value: Vector3, + hashedAddress: number, + isRehashing: boolean, + ): Entry { + const offset = hashedAddress * ELEMENTS_PER_ENTRY; + const texelOffset = offset / TEXTURE_CHANNEL_COUNT; + + const displacedEntry: Entry = [ + this.table[offset], + [this.table[offset + 1], this.table[offset + 2], this.table[offset + 3]], + ]; + + this.table[offset] = key; + this.table[offset + 1] = value[0]; + this.table[offset + 2] = value[1]; + this.table[offset + 3] = value[2]; + + if (!isRehashing) { + // Only partially update if we are not rehashing. Otherwise, it makes more + // sense to flush the entire texture content after the rehashing is done. + this._texture.update( + this.table.subarray(offset, offset + ELEMENTS_PER_ENTRY), + texelOffset % this.textureWidth, + Math.floor(texelOffset / this.textureWidth), + 1, + 1, + ); + } + + return displacedEntry; + } + + _hashCombine(state: number, value: number) { + // Based on Murmur3_32, since it is supported on the GPU. + // See https://github.com/tildeleb/cuckoo for a project + // written in golang which also supports Murmur hashes. + const k1 = 0xcc9e2d51; + const k2 = 0x1b873593; + + // eslint-disable-next-line no-param-reassign + value >>>= 0; + // eslint-disable-next-line no-param-reassign + state >>>= 0; + + // eslint-disable-next-line no-param-reassign + value = Math.imul(value, k1) >>> 0; + // eslint-disable-next-line no-param-reassign + value = ((value << 15) | (value >>> 17)) >>> 0; + // eslint-disable-next-line no-param-reassign + value = Math.imul(value, k2) >>> 0; + // eslint-disable-next-line no-param-reassign + state = (state ^ value) >>> 0; + // eslint-disable-next-line no-param-reassign + state = ((state << 13) | (state >>> 19)) >>> 0; + // eslint-disable-next-line no-param-reassign + state = (state * 5 + 0xe6546b64) >>> 0; + return state; + } + + _hashKeyToAddress(seed: number, key: number): number { + const state = this._hashCombine(seed, key); + + return state % this.entryCapacity; + } +} diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/data_rendering_logic.tsx b/frontend/javascripts/oxalis/model/bucket_data_handling/data_rendering_logic.tsx index e5d70ebf59d..096a1a24c66 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/data_rendering_logic.tsx +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/data_rendering_logic.tsx @@ -145,7 +145,7 @@ function getAvailableVoxelCount(textureSize: number, packingDegree: number) { return packingDegree * textureSize ** 2; } -function getTextureCount( +function getDataTextureCount( textureSize: number, packingDegree: number, requiredBucketCapacity: number, @@ -168,13 +168,13 @@ export function calculateTextureSizeAndCountForLayer( // Try to half the texture size as long as it does not require more // data textures while ( - getTextureCount(textureSize / 2, packingDegree, requiredBucketCapacity) <= - getTextureCount(textureSize, packingDegree, requiredBucketCapacity) + getDataTextureCount(textureSize / 2, packingDegree, requiredBucketCapacity) <= + getDataTextureCount(textureSize, packingDegree, requiredBucketCapacity) ) { textureSize /= 2; } - const textureCount = getTextureCount(textureSize, packingDegree, requiredBucketCapacity); + const textureCount = getDataTextureCount(textureSize, packingDegree, requiredBucketCapacity); return { textureSize, textureCount, @@ -205,46 +205,6 @@ function buildTextureInformationMap< return textureInformationPerLayer; } -function calculateNecessaryTextureCount( - textureInformationPerLayer: Map, -): number { - const layers = Array.from(textureInformationPerLayer.values()); - - const totalDataTextureCount = _.sum(layers.map((info) => info.textureCount)); - - const necessaryTextureCount = layers.length * lookupTextureCountPerLayer + totalDataTextureCount; - return necessaryTextureCount; -} - -function calculateMappingTextureCount(): number { - // If there is a segmentation layer, we need one lookup, one data and one color texture for mappings - const textureCountForCellMappings = 3; - return textureCountForCellMappings; -} - -function deriveSupportedFeatures( - specs: GpuSpecs, - textureInformationPerLayer: Map, - hasSegmentation: boolean, -): { - isMappingSupported: boolean; -} { - const necessaryTextureCount = calculateNecessaryTextureCount(textureInformationPerLayer); - let isMappingSupported = true; - // Count textures needed for mappings separately, because they are not strictly necessary - const notEnoughTexturesForMapping = - necessaryTextureCount + calculateMappingTextureCount() > specs.maxTextureCount; - - if (hasSegmentation && notEnoughTexturesForMapping) { - // Only mark mappings as unsupported if a segmentation exists - isMappingSupported = false; - } - - return { - isMappingSupported, - }; -} - function getSmallestCommonBucketCapacity< Layer extends { elementClass: ElementClass; @@ -264,20 +224,28 @@ function getRenderSupportedLayerCount< Layer extends { elementClass: ElementClass; }, ->(specs: GpuSpecs, textureInformationPerLayer: Map) { +>( + specs: GpuSpecs, + textureInformationPerLayer: Map, + hasSegmentation: boolean, +) { // Find out which layer needs the most textures. We assume that value is equal for all layers // so that we can tell the user that X layers can be rendered simultaneously. We could be more precise // here (because some layers might need fewer textures), but this would be harder to communicate to // the user and also more complex to maintain code-wise. - const maximumTextureCountForLayer = _.max( - Array.from(textureInformationPerLayer.values()).map( - (sizeAndCount) => sizeAndCount.textureCount, - ), - ); - + const maximumTextureCountForLayer = + _.max( + Array.from(textureInformationPerLayer.values()).map( + (sizeAndCount) => sizeAndCount.textureCount, + ), + ) ?? 0; + + // If a segmentation layer exists, we need to allocate a texture for custom colors, + // and two for mappings. + const textureCountForSegmentation = hasSegmentation ? 3 : 0; const maximumLayerCountToRender = Math.floor( - // @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'. - specs.maxTextureCount / (lookupTextureCountPerLayer + maximumTextureCountForLayer), + (specs.maxTextureCount - textureCountForSegmentation) / + (lookupTextureCountPerLayer + maximumTextureCountForLayer), ); return { maximumLayerCountToRender, @@ -306,19 +274,14 @@ export function computeDataTexturesSetup< const { maximumLayerCountToRender, maximumTextureCountForLayer } = getRenderSupportedLayerCount( specs, textureInformationPerLayer, + hasSegmentation, ); if (process.env.BABEL_ENV !== "test") { console.log("maximumLayerCountToRender", maximumLayerCountToRender); } - const { isMappingSupported } = deriveSupportedFeatures( - specs, - textureInformationPerLayer, - hasSegmentation, - ); return { - isMappingSupported, textureInformationPerLayer, smallestCommonBucketCapacity, maximumLayerCountToRender, diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts index ca513c08be3..a7622576a67 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/layer_rendering_manager.ts @@ -22,13 +22,20 @@ import AsyncBucketPickerWorker from "oxalis/workers/async_bucket_picker.worker"; import type DataCube from "oxalis/model/bucket_data_handling/data_cube"; import LatestTaskExecutor, { SKIPPED_TASK_REASON } from "libs/latest_task_executor"; import type PullQueue from "oxalis/model/bucket_data_handling/pullqueue"; -import Store from "oxalis/store"; +import Store, { SegmentMap } from "oxalis/store"; import TextureBucketManager from "oxalis/model/bucket_data_handling/texture_bucket_manager"; import UpdatableTexture from "libs/UpdatableTexture"; import type { ViewMode, OrthoViewMap, Vector3, Vector4 } from "oxalis/constants"; import constants from "oxalis/constants"; import shaderEditor from "oxalis/model/helpers/shader_editor"; import window from "libs/window"; +import DiffableMap from "libs/diffable_map"; +import { CuckooTable } from "./cuckoo_table"; +import { listenToStoreProperty } from "../helpers/listener_helpers"; +import { cachedDiffSegmentLists } from "../sagas/volumetracing_saga"; +import { getSegmentsForLayer } from "../accessors/volumetracing_accessor"; + +const CUSTOM_COLORS_TEXTURE_WIDTH = 512; const asyncBucketPickRaw = createWorker(AsyncBucketPickerWorker); const asyncBucketPick: typeof asyncBucketPickRaw = memoizeOne( @@ -99,21 +106,15 @@ function consumeBucketsFromArrayBuffer( } export default class LayerRenderingManager { - // @ts-expect-error ts-migrate(2564) FIXME: Property 'lastSphericalCapRadius' has no initializ... Remove this comment to see the full error message - lastSphericalCapRadius: number; + lastSphericalCapRadius: number | undefined; // Indicates whether the current position is closer to the previous or next bucket for each dimension // For example, if the current position is [31, 10, 25] the value would be [1, -1, 1] lastSubBucketLocality: Vector3 = [-1, -1, -1]; - // @ts-expect-error ts-migrate(2564) FIXME: Property 'lastAreas' has no initializer and is not... Remove this comment to see the full error message - lastAreas: OrthoViewMap; - // @ts-expect-error ts-migrate(2564) FIXME: Property 'lastZoomedMatrix' has no initializer and is ... Remove this comment to see the full error message - lastZoomedMatrix: Matrix4x4; - // @ts-expect-error ts-migrate(2564) FIXME: Property 'lastViewMode' has no initializer and is ... Remove this comment to see the full error message - lastViewMode: ViewMode; - // @ts-expect-error ts-migrate(2564) FIXME: Property 'lastIsVisible' has no initializer and is... Remove this comment to see the full error message - lastIsVisible: boolean; - // @ts-expect-error ts-migrate(2564) FIXME: Property 'textureBucketManager' has no initializer... Remove this comment to see the full error message - textureBucketManager: TextureBucketManager; + lastAreas: OrthoViewMap | undefined; + lastZoomedMatrix: Matrix4x4 | undefined; + lastViewMode: ViewMode | undefined; + lastIsVisible: boolean | undefined; + textureBucketManager!: TextureBucketManager; textureWidth: number; cube: DataCube; pullQueue: PullQueue; @@ -124,6 +125,9 @@ export default class LayerRenderingManager { currentBucketPickerTick: number = 0; latestTaskExecutor: LatestTaskExecutor = new LatestTaskExecutor(); + cuckooTable: CuckooTable | undefined; + storePropertyUnsubscribers: Array<() => void> = []; + constructor( name: string, pullQueue: PullQueue, @@ -156,6 +160,10 @@ export default class LayerRenderingManager { ); this.textureBucketManager.setupDataTextures(bytes); shaderEditor.addBucketManagers(this.textureBucketManager); + + if (this.cube.isSegmentation) { + this.listenToCustomSegmentColors(); + } } getDataTextures(): Array { @@ -304,4 +312,55 @@ export default class LayerRenderingManager { this.cachedAnchorPoint = anchorPoint; return true; } + + destroy() { + this.storePropertyUnsubscribers.forEach((fn) => fn()); + } + + /* Methods related to custom segment colors: */ + + getCustomColorCuckooTable() { + if (this.cuckooTable != null) { + return this.cuckooTable; + } + if (!this.cube.isSegmentation) { + throw new Error( + "getCustomColorCuckooTable should not be called for non-segmentation layers.", + ); + } + this.cuckooTable = new CuckooTable(CUSTOM_COLORS_TEXTURE_WIDTH); + return this.cuckooTable; + } + + listenToCustomSegmentColors() { + let prevSegments: SegmentMap = new DiffableMap(); + this.storePropertyUnsubscribers.push( + listenToStoreProperty( + (storeState) => getSegmentsForLayer(storeState, this.name), + (newSegments) => { + const cuckoo = this.getCustomColorCuckooTable(); + for (const updateAction of cachedDiffSegmentLists(prevSegments, newSegments)) { + if ( + updateAction.name === "updateSegment" || + updateAction.name === "createSegment" || + updateAction.name === "deleteSegment" + ) { + const { id } = updateAction.value; + const color = "color" in updateAction.value ? updateAction.value.color : null; + if (color != null) { + cuckoo.set( + id, + map3((el) => el * 255, color), + ); + } else { + cuckoo.unset(id); + } + } + } + + prevSegments = newSegments; + }, + ), + ); + } } diff --git a/frontend/javascripts/oxalis/model/bucket_data_handling/mappings.ts b/frontend/javascripts/oxalis/model/bucket_data_handling/mappings.ts index 0e3daf50aef..1b9d29119d1 100644 --- a/frontend/javascripts/oxalis/model/bucket_data_handling/mappings.ts +++ b/frontend/javascripts/oxalis/model/bucket_data_handling/mappings.ts @@ -9,31 +9,9 @@ import type { Mapping } from "oxalis/store"; import Store from "oxalis/store"; import UpdatableTexture from "libs/UpdatableTexture"; import messages from "messages"; + export const MAPPING_TEXTURE_WIDTH = 4096; -export const MAPPING_COLOR_TEXTURE_WIDTH = 16; export const MAPPING_MESSAGE_KEY = "mappings"; -// Remove soon (e.g., October 2021) -export function setupGlobalMappingsObject() { - return { - getAll(): string[] { - throw new Error( - "Using mappings.getAll() is deprecated. Please use the official front-end API function getMappingNames() instead.", - ); - }, - - getActive(): string | null | undefined { - throw new Error( - "Using mappings.getActive() is deprecated. Please use the official front-end API function getActiveMapping() instead.", - ); - }, - - activate(_mapping: string) { - throw new Error( - "Using mappings.activate() is deprecated. Please use the official front-end API function activateMapping() instead.", - ); - }, - }; -} class Mappings { layerName: string; @@ -41,8 +19,6 @@ class Mappings { mappingTexture: UpdatableTexture; // @ts-expect-error ts-migrate(2564) FIXME: Property 'mappingLookupTexture' has no initializer... Remove this comment to see the full error message mappingLookupTexture: UpdatableTexture; - // @ts-expect-error ts-migrate(2564) FIXME: Property 'mappingColorTexture' has no initializer ... Remove this comment to see the full error message - mappingColorTexture: UpdatableTexture; constructor(layerName: string) { this.layerName = layerName; @@ -67,23 +43,7 @@ class Mappings { THREE.UnsignedByteType, renderer, ); - // Up to 256 (16*16) custom colors can be specified for mappings - this.mappingColorTexture = createUpdatableTexture( - MAPPING_COLOR_TEXTURE_WIDTH, - 1, - THREE.FloatType, - renderer, - ); - // updateMappingColorTexture has to be called at least once to guarantee - // proper initialization of the texture with -1. - // There is a race condition otherwise leading to hard-to-debug errors. - listenToStoreProperty( - (state) => - getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, this.layerName) - .mappingColors, - (mappingColors) => this.updateMappingColorTexture(mappingColors), - true, - ); + listenToStoreProperty( (state) => getMappingInfo(state.temporaryConfiguration.activeMappingByLayer, this.layerName).mapping, @@ -98,22 +58,6 @@ class Mappings { ); } - updateMappingColorTexture(mappingColors: Array | null | undefined) { - mappingColors = mappingColors || []; - const maxNumberOfColors = MAPPING_COLOR_TEXTURE_WIDTH ** 2; - const float32Colors = new Float32Array(maxNumberOfColors); - // Initialize the array with -1 - float32Colors.fill(-1); - float32Colors.set(mappingColors.slice(0, maxNumberOfColors)); - this.mappingColorTexture.update( - float32Colors, - 0, - 0, - MAPPING_COLOR_TEXTURE_WIDTH, - MAPPING_COLOR_TEXTURE_WIDTH, - ); - } - async updateMappingTextures( mapping: Mapping | null | undefined, mappingKeys: Array | null | undefined, @@ -161,15 +105,11 @@ class Mappings { this.setupMappingTextures(); } - if ( - this.mappingTexture == null || - this.mappingLookupTexture == null || - this.mappingColorTexture == null - ) { + if (this.mappingTexture == null || this.mappingLookupTexture == null) { throw new Error("Mapping textures are null after initialization."); } - return [this.mappingTexture, this.mappingLookupTexture, this.mappingColorTexture]; + return [this.mappingTexture, this.mappingLookupTexture]; } } diff --git a/frontend/javascripts/oxalis/model/data_layer.ts b/frontend/javascripts/oxalis/model/data_layer.ts index 94c697aa267..9a301afee84 100644 --- a/frontend/javascripts/oxalis/model/data_layer.ts +++ b/frontend/javascripts/oxalis/model/data_layer.ts @@ -7,7 +7,7 @@ import Mappings from "oxalis/model/bucket_data_handling/mappings"; import PullQueue from "oxalis/model/bucket_data_handling/pullqueue"; import PushQueue from "oxalis/model/bucket_data_handling/pushqueue"; import type { DataLayerType } from "oxalis/store"; -import Store from "oxalis/store"; // TODO: Non-reactive +import Store from "oxalis/store"; class DataLayer { cube: DataCube; @@ -23,15 +23,20 @@ class DataLayer { constructor(layerInfo: DataLayerType, textureWidth: number, dataTextureCount: number) { this.name = layerInfo.name; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'fallbackLayer' does not exist on type 'A... Remove this comment to see the full error message - this.fallbackLayer = layerInfo.fallbackLayer != null ? layerInfo.fallbackLayer : null; + this.fallbackLayer = + "fallbackLayer" in layerInfo && layerInfo.fallbackLayer != null + ? layerInfo.fallbackLayer + : null; this.fallbackLayerInfo = - // @ts-expect-error ts-migrate(2339) FIXME: Property 'fallbackLayerInfo' does not exist on typ... Remove this comment to see the full error message - layerInfo.fallbackLayerInfo != null ? layerInfo.fallbackLayerInfo : null; + "fallbackLayerInfo" in layerInfo && layerInfo.fallbackLayerInfo != null + ? layerInfo.fallbackLayerInfo + : null; this.isSegmentation = layerInfo.category === "segmentation"; this.resolutions = layerInfo.resolutions; + const { dataset } = Store.getState(); ErrorHandling.assert(this.resolutions.length > 0, "Resolutions for layer cannot be empty"); + this.cube = new DataCube( getLayerBoundaries(dataset, this.name).upperBoundary, getResolutionInfo(this.resolutions), @@ -42,7 +47,11 @@ class DataLayer { this.pullQueue = new PullQueue(this.cube, layerInfo.name, dataset.dataStore); this.pushQueue = new PushQueue(this.cube); this.cube.initializeWithQueues(this.pullQueue, this.pushQueue); - if (this.isSegmentation) this.mappings = new Mappings(layerInfo.name); + + if (this.isSegmentation) { + this.mappings = new Mappings(layerInfo.name); + } + this.layerRenderingManager = new LayerRenderingManager( this.name, this.pullQueue, @@ -55,6 +64,7 @@ class DataLayer { destroy() { this.pullQueue.clear(); this.pushQueue.clear(); + this.layerRenderingManager.destroy(); } } diff --git a/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.ts b/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.ts index a95fa0e6921..6ab8bab4525 100644 --- a/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.ts +++ b/frontend/javascripts/oxalis/model/helpers/compaction/compact_save_queue.ts @@ -63,6 +63,28 @@ function removeSubsequentUpdateNodeActions(updateActionsBatches: Array) { + const obsoleteUpdateActions = []; + + // If two updateSegment update actions for the same segment id follow one another, the first one is obsolete + for (let i = 0; i < updateActionsBatches.length - 1; i++) { + const actions1 = updateActionsBatches[i].actions; + const actions2 = updateActionsBatches[i + 1].actions; + + if ( + actions1.length === 1 && + actions1[0].name === "updateSegment" && + actions2.length === 1 && + actions2[0].name === "updateSegment" && + actions1[0].value.id === actions2[0].value.id + ) { + obsoleteUpdateActions.push(updateActionsBatches[i]); + } + } + + return _.without(updateActionsBatches, ...obsoleteUpdateActions); +} + export default function compactSaveQueue( updateActionsBatches: Array, ): Array { @@ -70,9 +92,12 @@ export default function compactSaveQueue( const result = updateActionsBatches.filter( (updateActionsBatch) => updateActionsBatch.actions.length > 0, ); - return removeSubsequentUpdateTreeActions( - removeSubsequentUpdateNodeActions( - removeAllButLastUpdateTdCameraAction(removeAllButLastUpdateTracingAction(result)), + + return removeSubsequentUpdateSegmentActions( + removeSubsequentUpdateTreeActions( + removeSubsequentUpdateNodeActions( + removeAllButLastUpdateTdCameraAction(removeAllButLastUpdateTracingAction(result)), + ), ), ); } diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts index 20569967832..ff7f2931ddd 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts @@ -115,15 +115,13 @@ function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { if (segment.somePosition) { somePosition = Utils.floor3(segment.somePosition); - } else { - if (oldSegment == null) { - // UPDATE_SEGMENT was called for a non-existing segment without providing - // a position. Ignore this action, as the a segment cannot be created without - // a position. - return state; - } - + } else if (oldSegment != null) { somePosition = oldSegment.somePosition; + } else { + // UPDATE_SEGMENT was called for a non-existing segment without providing + // a position. This is necessary to define custom colors for segments + // which are listed in a JSON mapping. The action will store the segment + // without a position. } const newSegment = { @@ -131,6 +129,7 @@ function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { // used by ...oldSegment creationTime: action.timestamp, name: null, + color: null, ...oldSegment, ...segment, somePosition, @@ -165,7 +164,10 @@ export function serverVolumeToClientVolumeTracing(tracing: ServerVolumeTracing): { ...segment, id: segment.segmentId, - somePosition: Utils.point3ToVector3(segment.anchorPosition), + somePosition: segment.anchorPosition + ? Utils.point3ToVector3(segment.anchorPosition) + : undefined, + color: segment.color != null ? Utils.colorObjectToRGBArray(segment.color) : null, }, ]), ), diff --git a/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts b/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts index da9677fdf40..9ed29a8afd7 100644 --- a/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/isosurface_saga.ts @@ -59,6 +59,7 @@ import Toast from "libs/toast"; import messages from "messages"; import processTaskWithPool from "libs/task_pool"; import { getBaseSegmentationName } from "oxalis/view/right-border-tabs/segments_tab/segments_view_helper"; +import { UpdateSegmentAction } from "../actions/volumetracing_actions"; const MAX_RETRY_COUNT = 5; const RETRY_WAIT_TIME = 5000; const MESH_CHUNK_THROTTLE_DELAY = 500; @@ -665,10 +666,18 @@ function* handleIsosurfaceVisibilityChange(action: UpdateIsosurfaceVisibilityAct SceneController.setIsosurfaceVisibility(id, visibility); } +function* handleIsosurfaceColorChange(action: UpdateSegmentAction): Saga { + const SceneController = yield* call(getSceneController); + if ("color" in action.segment) { + SceneController.setIsosurfaceColor(action.segmentId); + } +} + export default function* isosurfaceSaga(): Saga { // Buffer actions since they might be dispatched before WK_READY const loadAdHocMeshActionChannel = yield* actionChannel("LOAD_AD_HOC_MESH_ACTION"); const loadPrecomputedMeshActionChannel = yield* actionChannel("LOAD_PRECOMPUTED_MESH_ACTION"); + yield* take("SCENE_CONTROLLER_READY"); yield* take("WK_READY"); yield* takeEvery(loadAdHocMeshActionChannel, loadAdHocIsosurfaceFromAction); yield* takeEvery(loadPrecomputedMeshActionChannel, loadPrecomputedMesh); @@ -679,4 +688,5 @@ export default function* isosurfaceSaga(): Saga { yield* takeEvery("REFRESH_ISOSURFACE", refreshIsosurface); yield* takeEvery("UPDATE_ISOSURFACE_VISIBILITY", handleIsosurfaceVisibilityChange); yield* takeEvery(["START_EDITING", "COPY_SEGMENTATION_LAYER"], markEditedCellAsDirty); + yield* takeEvery(["UPDATE_SEGMENT"], handleIsosurfaceColorChange); } diff --git a/frontend/javascripts/oxalis/model/sagas/mapping_saga.ts b/frontend/javascripts/oxalis/model/sagas/mapping_saga.ts index a7714c078ad..e6ded555536 100644 --- a/frontend/javascripts/oxalis/model/sagas/mapping_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/mapping_saga.ts @@ -4,6 +4,7 @@ import { all, call, takeEvery, takeLatest, take, put, fork, actionChannel } from import { select } from "oxalis/model/sagas/effect-generators"; import { message } from "antd"; import type { + OptionalMappingProperties, SetMappingAction, SetMappingEnabledAction, } from "oxalis/model/actions/settings_actions"; @@ -23,6 +24,8 @@ import api from "oxalis/api/internal_api"; import { MappingStatusEnum } from "oxalis/constants"; import { isMappingActivationAllowed } from "oxalis/model/accessors/volumetracing_accessor"; import Toast from "libs/toast"; +import { jsHsv2rgb } from "oxalis/shaders/utils.glsl"; +import { updateSegmentAction } from "../actions/volumetracing_actions"; type APIMappings = Record; const isAgglomerate = (mapping: ActiveMappingInfo) => { @@ -92,7 +95,20 @@ function* maybeFetchMapping( ); if (!isEditableMappingActivationAllowed) return; - if (mappingName == null || existingMapping != null) return; + if (mappingName == null) { + return; + } + if (existingMapping != null) { + // A fully fledged mapping object was already passed + // (e.g., via the front-end API). + // Only the custom colors have to be configured, if they + // were passed. + if (action.mappingColors) { + const classes = convertMappingObjectToClasses(existingMapping); + yield* call(setCustomColors, action, classes, layerName); + } + return; + } if (showLoadingIndicator) { message.loading({ @@ -109,11 +125,9 @@ function* maybeFetchMapping( "fallbackLayer" in layerInfo && layerInfo.fallbackLayer != null ? layerInfo.fallbackLayer : layerInfo.name, - ]; + ] as const; const [jsonMappings, serverHdf5Mappings] = yield* all([ - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. call(getMappingsForDatasetLayer, ...params), - // @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call. call(getAgglomeratesForDatasetLayer, ...params), ]); // Make sure the available mappings are persisted in the store if they are not already @@ -171,16 +185,18 @@ function* maybeFetchMapping( yield* put(setMappingAction(layerName, null, mappingType)); return; } - const { hideUnmappedIds, colors: mappingColors } = fetchedMappings[mappingName]; + const fetchedMapping = fetchedMappings[mappingName]; + const { hideUnmappedIds, colors: mappingColors } = fetchedMapping; + // If custom colors are specified for a mapping, assign the mapped ids specifically, so that the first equivalence // class will get the first color, and so on - const assignNewIds = mappingColors != null && mappingColors.length > 0; + const usesCustomColors = mappingColors != null && mappingColors.length > 0; const [mappingObject, mappingKeys] = yield* call( buildMappingObject, layerName, mappingName, fetchedMappings, - assignNewIds, + usesCustomColors, // assign new ids when using custom colors ); const mappingProperties = { mapping: mappingObject, @@ -189,6 +205,10 @@ function* maybeFetchMapping( hideUnmappedIds, }; + if (usesCustomColors) { + yield* call(setCustomColors, mappingProperties, fetchedMapping.classes || [], layerName); + } + if (layerInfo.elementClass === "uint64") { yield* call( [Toast, Toast.warning], @@ -200,6 +220,42 @@ function* maybeFetchMapping( yield* put(setMappingAction(layerName, mappingName, mappingType, mappingProperties)); } +function convertMappingObjectToClasses(existingMapping: Mapping) { + const classesByRepresentative: Record = {}; + for (const unmappedStr of Object.keys(existingMapping)) { + const unmapped = Number(unmappedStr); + const mapped = existingMapping[unmapped]; + classesByRepresentative[mapped] = classesByRepresentative[mapped] || []; + classesByRepresentative[mapped].push(unmapped); + } + const classes = Object.values(classesByRepresentative); + return classes; +} + +function* setCustomColors( + mappingProperties: OptionalMappingProperties, + classes: number[][], + layerName: string, +) { + if (mappingProperties.mapping == null || mappingProperties.mappingColors == null) { + return; + } + let classIdx = 0; + for (const aClass of classes) { + const firstIdEntry = aClass[0]; + if (firstIdEntry == null) { + continue; + } + const representativeId = mappingProperties.mapping[firstIdEntry]; + + const hueValue = mappingProperties.mappingColors[classIdx]; + const color = jsHsv2rgb(360 * hueValue, 1, 1); + yield* put(updateSegmentAction(representativeId, { color }, layerName)); + + classIdx++; + } +} + function* fetchMappings( layerName: string, mappingName: string, @@ -249,8 +305,7 @@ function* buildMappingObject( const mappingKeys = []; const largestSegmentID = yield* call(getLargestSegmentId, layerName); const maxId = largestSegmentID + 1; - // Initialize to the next multiple of 256 that is larger than maxId - let newMappedId = Math.ceil(maxId / 256) * 256; + let newMappedId = maxId; for (const currentMappingName of getMappingChain(mappingName, fetchedMappings)) { const mapping = fetchedMappings[currentMappingName]; @@ -259,19 +314,20 @@ function* buildMappingObject( "Mappings must have been fetched at this point. Ensure that the mapping JSON contains a classes property.", ); - if (mapping.classes) { - for (const mappingClass of mapping.classes) { - const minId = assignNewIds ? newMappedId : _.min(mappingClass); - // @ts-expect-error ts-migrate(2538) FIXME: Type 'undefined' cannot be used as an index type. - const mappedId = mappingObject[minId] || minId; - - for (const id of mappingClass) { - mappingObject[id] = mappedId; - mappingKeys.push(id); - } + for (const mappingClass of mapping.classes) { + const minId = assignNewIds ? newMappedId : _.min(mappingClass); + if (minId == null) { + // The class is empty and can be ignored + continue; + } + const mappedId = mappingObject[minId] || minId; - newMappedId++; + for (const id of mappingClass) { + mappingObject[id] = mappedId; + mappingKeys.push(id); } + + newMappedId++; } } diff --git a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts index 4df83ba7b77..f242e861003 100644 --- a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts @@ -78,6 +78,7 @@ import { deleteConnectomeTreesAction, } from "oxalis/model/actions/connectome_actions"; import type { ServerSkeletonTracing } from "types/api_flow_types"; +import memoizeOne from "memoize-one"; function* centerActiveNode(action: Action): Saga { if ("suppressCentering" in action && action.suppressCentering) { @@ -594,22 +595,11 @@ export function* diffTrees( } } } -const diffTreeCache = {}; -export function cachedDiffTrees(prevTrees: TreeMap, trees: TreeMap): Array { - // Try to use the cached version of the diff if available to increase performance - // @ts-expect-error ts-migrate(2339) FIXME: Property 'prevTrees' does not exist on type '{}'. - if (prevTrees !== diffTreeCache.prevTrees || trees !== diffTreeCache.trees) { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'prevTrees' does not exist on type '{}'. - diffTreeCache.prevTrees = prevTrees; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'trees' does not exist on type '{}'. - diffTreeCache.trees = trees; - // @ts-expect-error ts-migrate(2339) FIXME: Property 'diff' does not exist on type '{}'. - diffTreeCache.diff = Array.from(diffTrees(prevTrees, trees)); - } - // @ts-expect-error ts-migrate(2339) FIXME: Property 'diff' does not exist on type '{}'. - return diffTreeCache.diff; -} +export const cachedDiffTrees = memoizeOne((prevTrees: TreeMap, trees: TreeMap) => + Array.from(diffTrees(prevTrees, trees)), +); + export function* diffSkeletonTracing( prevSkeletonTracing: SkeletonTracing, skeletonTracing: SkeletonTracing, @@ -617,8 +607,9 @@ export function* diffSkeletonTracing( flycam: Flycam, ): Generator { if (prevSkeletonTracing !== skeletonTracing) { - // @ts-expect-error ts-migrate(2766) FIXME: Cannot delegate iteration to value because the 'ne... Remove this comment to see the full error message - yield* cachedDiffTrees(prevSkeletonTracing.trees, skeletonTracing.trees); + for (const action of cachedDiffTrees(prevSkeletonTracing.trees, skeletonTracing.trees)) { + yield action; + } if (prevSkeletonTracing.treeGroups !== skeletonTracing.treeGroups) { yield updateTreeGroups(skeletonTracing.treeGroups); diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.ts b/frontend/javascripts/oxalis/model/sagas/update_actions.ts index 0055d879a43..f3322a24107 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.ts +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.ts @@ -119,6 +119,7 @@ export type CreateSegmentUpdateAction = { id: number; anchorPosition: Vector3 | null | undefined; name: string | null | undefined; + color: Vector3 | null; creationTime: number | null | undefined; }; }; @@ -128,6 +129,7 @@ export type UpdateSegmentUpdateAction = { id: number; anchorPosition: Vector3 | null | undefined; name: string | null | undefined; + color: Vector3 | null; creationTime: number | null | undefined; }; }; @@ -438,6 +440,7 @@ export function createSegmentVolumeAction( id: number, anchorPosition: Vector3 | null | undefined, name: string | null | undefined, + color: Vector3 | null, creationTime: number | null | undefined = Date.now(), ): CreateSegmentUpdateAction { return { @@ -446,6 +449,7 @@ export function createSegmentVolumeAction( id, anchorPosition, name, + color, creationTime, }, }; @@ -454,6 +458,7 @@ export function updateSegmentVolumeAction( id: number, anchorPosition: Vector3 | null | undefined, name: string | null | undefined, + color: Vector3 | null, creationTime: number | null | undefined = Date.now(), ): UpdateSegmentUpdateAction { return { @@ -463,6 +468,7 @@ export function updateSegmentVolumeAction( anchorPosition, name, creationTime, + color, }, }; } diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx index 07dc1adbd7e..5333255526e 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx @@ -5,6 +5,7 @@ import createProgressCallback from "libs/progress_callback"; import Toast from "libs/toast"; import * as Utils from "libs/utils"; import _ from "lodash"; +import memoizeOne from "memoize-one"; import type { AnnotationTool, BoundingBoxType, @@ -516,7 +517,12 @@ function updateTracingPredicate( ); } -export function* diffSegmentLists( +export const cachedDiffSegmentLists = memoizeOne( + (prevSegments: SegmentMap, newSegments: SegmentMap) => + Array.from(uncachedDiffSegmentLists(prevSegments, newSegments)), +); + +function* uncachedDiffSegmentLists( prevSegments: SegmentMap, newSegments: SegmentMap, ): Generator { @@ -532,7 +538,7 @@ export function* diffSegmentLists( for (const segmentId of addedSegmentIds) { const segment = newSegments.get(segmentId); - yield createSegmentVolumeAction(segment.id, segment.somePosition, segment.name); + yield createSegmentVolumeAction(segment.id, segment.somePosition, segment.name, segment.color); } for (const segmentId of bothSegmentIds) { @@ -544,6 +550,7 @@ export function* diffSegmentLists( segment.id, segment.somePosition, segment.name, + segment.color, segment.creationTime, ); } @@ -569,7 +576,12 @@ export function* diffVolumeTracing( } if (prevVolumeTracing.segments !== volumeTracing.segments) { - yield* diffSegmentLists(prevVolumeTracing.segments, volumeTracing.segments); + for (const action of cachedDiffSegmentLists( + prevVolumeTracing.segments, + volumeTracing.segments, + )) { + yield action; + } } if (prevVolumeTracing.fallbackLayer != null && volumeTracing.fallbackLayer == null) { @@ -601,7 +613,12 @@ function* ensureSegmentExists( const segments = yield* select((store) => getSegmentsForLayer(store, layerName)); const cellId = action.type === "UPDATE_TEMPORARY_SETTING" ? action.value : action.cellId; - if (cellId === 0 || cellId == null || segments == null || segments.getNullable(cellId) != null) { + if ( + cellId === 0 || + cellId == null || + // If the segment was already registered with a position, don't do anything + segments.getNullable(cellId)?.somePosition != null + ) { return; } diff --git a/frontend/javascripts/oxalis/model_initialization.ts b/frontend/javascripts/oxalis/model_initialization.ts index 5555df384d4..2cce1012406 100644 --- a/frontend/javascripts/oxalis/model_initialization.ts +++ b/frontend/javascripts/oxalis/model_initialization.ts @@ -76,7 +76,6 @@ import { loadAdHocMeshAction, loadPrecomputedMeshAction, } from "oxalis/model/actions/segmentation_actions"; -import { setupGlobalMappingsObject } from "oxalis/model/bucket_data_handling/mappings"; import DataLayer from "oxalis/model/data_layer"; import ErrorHandling from "libs/error_handling"; import type { @@ -92,7 +91,6 @@ import UrlManager from "oxalis/controller/url_manager"; import * as Utils from "libs/utils"; import constants, { ControlModeEnum, AnnotationToolEnum, Vector3 } from "oxalis/constants"; import messages from "messages"; -import window from "libs/window"; import { setActiveConnectomeAgglomerateIdsAction, updateCurrentConnectomeFileAction, @@ -109,7 +107,6 @@ export async function initialize( ): Promise< | { dataLayers: DataLayerCollection; - isMappingSupported: boolean; maximumTextureCountForLayer: number; } | null @@ -258,10 +255,6 @@ function validateSpecsForLayers(dataset: APIDataset, requiredBucketCapacity: num requiredBucketCapacity, ); - if (!setupDetails.isMappingSupported) { - console.warn(messages["mapping.too_few_textures"]); - } - maybeWarnAboutUnsupportedLayers(layers); return setupDetails; } @@ -434,7 +427,6 @@ function initializeSettings( function initializeDataLayerInstances(gpuFactor: number | null | undefined): { dataLayers: DataLayerCollection; - isMappingSupported: boolean; maximumTextureCountForLayer: number; smallestCommonBucketCapacity: number; maximumLayerCountToRender: number; @@ -445,7 +437,6 @@ function initializeDataLayerInstances(gpuFactor: number | null | undefined): { (gpuFactor != null ? gpuFactor : constants.DEFAULT_GPU_MEMORY_FACTOR); const { textureInformationPerLayer, - isMappingSupported, smallestCommonBucketCapacity, maximumLayerCountToRender, maximumTextureCountForLayer, @@ -473,11 +464,6 @@ function initializeDataLayerInstances(gpuFactor: number | null | undefined): { ); } - if (hasSegmentation(dataset) != null && isMappingSupported) { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'mappings' does not exist on type '(Windo... Remove this comment to see the full error message - window.mappings = setupGlobalMappingsObject(); - } - if (getDataLayers(dataset).length === 0) { Toast.error(messages["dataset.no_data"]); throw HANDLED_ERROR; @@ -485,7 +471,6 @@ function initializeDataLayerInstances(gpuFactor: number | null | undefined): { return { dataLayers, - isMappingSupported, maximumTextureCountForLayer, smallestCommonBucketCapacity, maximumLayerCountToRender, diff --git a/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.ts b/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.ts index 315f9b61abe..23bae13bb0c 100644 --- a/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.ts +++ b/frontend/javascripts/oxalis/shaders/main_data_fragment.glsl.ts @@ -1,8 +1,5 @@ import _ from "lodash"; -import { - MAPPING_TEXTURE_WIDTH, - MAPPING_COLOR_TEXTURE_WIDTH, -} from "oxalis/model/bucket_data_handling/mappings"; +import { MAPPING_TEXTURE_WIDTH } from "oxalis/model/bucket_data_handling/mappings"; import type { Vector3 } from "oxalis/constants"; import constants, { ViewModeValuesIndices, OrthoViewIndices } from "oxalis/constants"; import { convertCellIdToRGB, getBrushOverlay, getSegmentationId } from "./segmentation.glsl"; @@ -14,7 +11,6 @@ type Params = { colorLayerNames: string[]; segmentationLayerNames: string[]; packingDegreeLookup: Record; - isMappingSupported: boolean; dataTextureCountPerLayer: number; resolutions: Array; datasetScale: Vector3; @@ -54,20 +50,27 @@ const int dataTextureCountPerLayer = <%= dataTextureCountPerLayer %>; <% }) %> <% if (hasSegmentation) { %> + // Custom color cuckoo table + uniform highp usampler2D custom_color_texture; + uniform highp uint seed0; + uniform highp uint seed1; + uniform highp uint seed2; + uniform highp uint CUCKOO_ENTRY_CAPACITY; + uniform highp uint CUCKOO_ELEMENTS_PER_ENTRY; + uniform highp uint CUCKOO_ELEMENTS_PER_TEXEL; + uniform highp uint CUCKOO_TWIDTH; + uniform vec4 activeCellIdHigh; uniform vec4 activeCellIdLow; uniform bool isMouseInActiveViewport; uniform bool showBrush; uniform float segmentationPatternOpacity; - <% if (isMappingSupported) { %> - uniform bool isMappingEnabled; - uniform float mappingSize; - uniform bool hideUnmappedIds; - uniform sampler2D segmentation_mapping_texture; - uniform sampler2D segmentation_mapping_lookup_texture; - uniform sampler2D segmentation_mapping_color_texture; - <% } %> + uniform bool isMappingEnabled; + uniform float mappingSize; + uniform bool hideUnmappedIds; + uniform sampler2D segmentation_mapping_texture; + uniform sampler2D segmentation_mapping_lookup_texture; <% } %> uniform float sphericalCapRadius; @@ -98,6 +101,7 @@ const float bucketWidth = <%= bucketWidth %>; const float bucketSize = <%= bucketSize %>; const float l_texture_width = <%= l_texture_width %>; + // For some reason, taking the dataset scale from the uniform results in imprecise // rendering of the brush circle (and issues in the arbitrary modes). That's why it // is directly inserted into the source via templating. @@ -223,10 +227,9 @@ void main() { bucketSize: formatNumberAsGLSLFloat(constants.BUCKET_SIZE), l_texture_width: formatNumberAsGLSLFloat(params.lookupTextureWidth), mappingTextureWidth: formatNumberAsGLSLFloat(MAPPING_TEXTURE_WIDTH), - mappingColorTextureWidth: formatNumberAsGLSLFloat(MAPPING_COLOR_TEXTURE_WIDTH), formatNumberAsGLSLFloat, - // @ts-expect-error ts-migrate(7006) FIXME: Parameter 'vector3' implicitly has an 'any' type. - formatVector3AsVec3: (vector3) => `vec3(${vector3.map(formatNumberAsGLSLFloat).join(", ")})`, + formatVector3AsVec3: (vector3: Vector3) => + `vec3(${vector3.map(formatNumberAsGLSLFloat).join(", ")})`, OrthoViewIndices: _.mapValues(OrthoViewIndices, formatNumberAsGLSLFloat), hasSegmentation, }); diff --git a/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts b/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts index 61c138cf1b1..15892bcb42b 100644 --- a/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts +++ b/frontend/javascripts/oxalis/shaders/segmentation.glsl.ts @@ -14,6 +14,46 @@ import { getRgbaAtIndex } from "./texture_access.glsl"; export const convertCellIdToRGB: ShaderModule = { requirements: [hsvToRgb, getRgbaAtIndex, getElementOfPermutation, aaStep, colormapJet], code: ` + highp uint hashCombine(highp uint state, highp uint value) { + // The used constants are written in decimal, because + // the parser tests don't support unsigned int hex notation + // (yet). + // See this issue: https://github.com/ShaderFrog/glsl-parser/issues/1 + // 3432918353u == 0xcc9e2d51u + // 461845907u == 0x1b873593u + // 3864292196u == 0xe6546b64u + + value *= 3432918353u; + value = (value << 15u) | (value >> 17u); + value *= 461845907u; + state ^= value; + state = (state << 13u) | (state >> 19u); + state = (state * 5u) + 3864292196u; + return state; + } + + uint vec4ToUint(vec4 idLow) { + uint integerValue = (uint(idLow.a) << 24) | (uint(idLow.b) << 16) | (uint(idLow.g) << 8) | uint(idLow.r); + return integerValue; + } + + vec3 attemptCustomColorLookUp(uint integerValue, uint seed) { + highp uint h0 = hashCombine(seed, integerValue) % CUCKOO_ENTRY_CAPACITY; + h0 = uint(h0 * CUCKOO_ELEMENTS_PER_ENTRY / CUCKOO_ELEMENTS_PER_TEXEL); + highp uint x = h0 % CUCKOO_TWIDTH; + highp uint y = h0 / CUCKOO_TWIDTH; + + uvec4 customEntry = texelFetch(custom_color_texture, ivec2(x, y), 0); + uvec3 customColor = customEntry.gba; + + if (customEntry.r != uint(integerValue)) { + return vec3(-1); + } + + return vec3(customEntry.gba) / 255.; + } + + vec3 convertCellIdToRGB(vec4 idHigh, vec4 idLow) { /* This function maps from a segment id to a color with a pattern. @@ -34,31 +74,32 @@ export const convertCellIdToRGB: ShaderModule = { // Since collisions of ids are bound to happen, using all 64 bits is not // necessary, which is why we simply combine the 32-bit tuple into one 32-bit value. vec4 id = idHigh + idLow; - float lastEightBits = id.r; float significantSegmentIndex = 256.0 * id.g + id.r; float colorCount = 19.; float colorIndex = getElementOfPermutation(significantSegmentIndex, colorCount, 2.); float colorValueDecimal = 1.0 / colorCount * colorIndex; - float colorValue = rgb2hsv(colormapJet(colorValueDecimal)).x; - // For historical reference: the old color generation was: colorValue = mod(lastEightBits * (golden_ratio - 1.0), 1.0); - - <% if (isMappingSupported) { %> - // If the first element of the mapping colors texture is still the initialized - // colorValue of -1, no mapping colors have been specified - bool hasCustomMappingColors = getRgbaAtIndex( - segmentation_mapping_color_texture, - <%= mappingColorTextureWidth %>, - 0.0 - ).r != -1.0; - if (isMappingEnabled && hasCustomMappingColors) { - colorValue = getRgbaAtIndex( - segmentation_mapping_color_texture, - <%= mappingColorTextureWidth %>, - lastEightBits - ).r; - } - <% } %> + float colorHue = rgb2hsv(colormapJet(colorValueDecimal)).x; + float colorSaturation = 1.; + float colorValue = 1.; + // For historical reference: the old color generation was: + // float lastEightBits = id.r; + // float colorHue = mod(lastEightBits * (golden_ratio - 1.0), 1.0); + + uint integerValue = vec4ToUint(idLow); + vec3 customColor = attemptCustomColorLookUp(integerValue, seed0); + if (customColor.r == -1.) { + customColor = attemptCustomColorLookUp(integerValue, seed1); + } + if (customColor.r == -1.) { + customColor = attemptCustomColorLookUp(integerValue, seed2); + } + if (customColor.r != -1.) { + vec3 customHSV = rgb2hsv(customColor); + colorHue = customHSV.x; + colorSaturation = customHSV.y; + colorValue = customHSV.z; + } // The following code scales the world coordinates so that the coordinate frequency is in a "pleasant" range. // Also, when zooming out, coordinates change faster which make the pattern more turbulent. Dividing by the @@ -114,9 +155,9 @@ export const convertCellIdToRGB: ShaderModule = { float aaStripeValue = 1.0 - max(aaStripeValueA, useGrid * aaStripeValueB); vec4 HSV = vec4( - colorValue, - 1.0 - 0.5 * ((1. - aaStripeValue) * segmentationPatternOpacity / 100.0), - 1.0 - 0.5 * (aaStripeValue * segmentationPatternOpacity / 100.0), + colorHue, + colorSaturation - 0.5 * ((1. - aaStripeValue) * segmentationPatternOpacity / 100.0), + colorValue - 0.5 * (aaStripeValue * segmentationPatternOpacity / 100.0), 1.0 ); @@ -208,27 +249,25 @@ export const getSegmentationId: ShaderModule = { volume_color[1] = vec4(volume_color[1].r, volume_color[1].g, 0.0, 0.0); <% } %> - <% if (isMappingSupported) { %> - if (isMappingEnabled) { - // Note that currently only the lower 32 bits of the segmentation - // are used for applying the JSON mapping. + if (isMappingEnabled) { + // Note that currently only the lower 32 bits of the segmentation + // are used for applying the JSON mapping. - float index = binarySearchIndex( - segmentation_mapping_lookup_texture, - mappingSize, - volume_color[1] + float index = binarySearchIndex( + segmentation_mapping_lookup_texture, + mappingSize, + volume_color[1] + ); + if (index != -1.0) { + volume_color[1] = getRgbaAtIndex( + segmentation_mapping_texture, + <%= mappingTextureWidth %>, + index ); - if (index != -1.0) { - volume_color[1] = getRgbaAtIndex( - segmentation_mapping_texture, - <%= mappingTextureWidth %>, - index - ); - } else if (hideUnmappedIds) { - volume_color[1] = vec4(0.0); - } + } else if (hideUnmappedIds) { + volume_color[1] = vec4(0.0); } - <% } %> + } volume_color[0] *= 255.0; volume_color[1] *= 255.0; diff --git a/frontend/javascripts/oxalis/shaders/utils.glsl.ts b/frontend/javascripts/oxalis/shaders/utils.glsl.ts index 5310a94dee0..c98b11f495c 100644 --- a/frontend/javascripts/oxalis/shaders/utils.glsl.ts +++ b/frontend/javascripts/oxalis/shaders/utils.glsl.ts @@ -1,4 +1,5 @@ import _ from "lodash"; +import { Vector3, Vector4 } from "oxalis/constants"; import type { ShaderModule } from "./shader_module_system"; export const hsvToRgb: ShaderModule = { requirements: [], @@ -26,9 +27,17 @@ export const hsvToRgb: ShaderModule = { } `, }; + +// From: https://stackoverflow.com/a/54024653 +// input: h in [0,360] and s,v in [0,1] - output: r,g,b in [0,1] +export function jsHsv2rgb(h: number, s: number, v: number): Vector3 { + const f = (n: number, k = (n + h / 60) % 6) => v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); + return [f(5), f(3), f(1)]; +} + // From: https://stackoverflow.com/a/54070620/896760 // Input: r,g,b in [0,1], out: h in [0,360) and s,v in [0,1] -export function jsRgb2hsv(rgb: [number, number, number]): [number, number, number] { +export function jsRgb2hsv(rgb: Vector3): Vector3 { const [r, g, b] = rgb; const v = Math.max(r, g, b); const n = v - Math.min(r, g, b); @@ -37,6 +46,48 @@ export function jsRgb2hsv(rgb: [number, number, number]): [number, number, numbe // @ts-expect-error ts-migrate(2365) FIXME: Operator '+' cannot be applied to types 'number | ... Remove this comment to see the full error message return [60 * (h < 0 ? h + 6 : h), v && n / v, v]; } + +/** + * Adapted from https://stackoverflow.com/a/9493060. + * + * Converts an RGB color value to HSL. Conversion formula + * adapted from http://en.wikipedia.org/wiki/HSL_color_space. + * Assumes r, g, and b are contained in the set [0, 1] and + * returns h, s, and l in the set [0, 1]. + */ +export function jsRgb2hsl(rgb: Vector3): Vector3 { + const [r, g, b] = rgb; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h; + let s; + const l = (max + min) / 2; + + if (max === min) { + // achromatic + h = 0; + s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + default: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + + return [h, s, l]; +} + export const colormapJet: ShaderModule = { requirements: [], code: ` @@ -51,7 +102,7 @@ export const colormapJet: ShaderModule = { }; // Input in [0,1] // Output in [0,1] for r, g and b -export function jsColormapJet(x: number): [number, number, number] { +export function jsColormapJet(x: number): Vector3 { const r = _.clamp(x < 0.89 ? (x - 0.35) / 0.31 : 1.0 - ((x - 0.89) / 0.11) * 0.5, 0, 1); const g = _.clamp(x < 0.64 ? (x - 0.125) * 4.0 : 1.0 - (x - 0.64) / 0.27, 0, 1); @@ -60,6 +111,10 @@ export function jsColormapJet(x: number): [number, number, number] { return [r, g, b]; } +export const hslaToCSS = (hsla: Vector4) => { + const [h, s, l, a] = hsla; + return `hsla(${360 * h}, ${100 * s}%, ${100 * l}%, ${a})`; +}; export const aaStep: ShaderModule = { requirements: [], code: ` diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index 8f9aa6d3617..3b186474b2a 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -211,8 +211,9 @@ export type SkeletonTracing = TracingBase & { export type Segment = { id: number; name: string | null | undefined; - somePosition: Vector3; + somePosition: Vector3 | undefined; creationTime: number | null | undefined; + color: Vector3 | null; }; export type SegmentMap = DiffableMap; diff --git a/frontend/javascripts/oxalis/view/action-bar/save_button.tsx b/frontend/javascripts/oxalis/view/action-bar/save_button.tsx index 5bde7bb9e08..5d38914dca7 100644 --- a/frontend/javascripts/oxalis/view/action-bar/save_button.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/save_button.tsx @@ -62,12 +62,10 @@ class SaveButton extends React.PureComponent { componentDidMount() { // Polling can be removed once VolumeMode saving is reactive - // @ts-expect-error ts-migrate(2339) FIXME: Property 'setInterval' does not exist on type '(Wi... Remove this comment to see the full error message this.savedPollingInterval = window.setInterval(this._forceUpdate, SAVE_POLLING_INTERVAL); } componentWillUnmount() { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'clearInterval' does not exist on type '(... Remove this comment to see the full error message window.clearInterval(this.savedPollingInterval); } diff --git a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx index a6eeecd99a7..28bea6edd03 100644 --- a/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx +++ b/frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx @@ -1,11 +1,10 @@ import { Radio, Tooltip, Badge, Space, Popover, RadioChangeEvent, Dropdown, Menu } from "antd"; import { ClearOutlined, DownOutlined, ExportOutlined } from "@ant-design/icons"; import { useSelector, useDispatch } from "react-redux"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useCallback, useState } from "react"; import { LogSliderSetting } from "oxalis/view/components/setting_input_views"; import { addUserBoundingBoxAction } from "oxalis/model/actions/annotation_actions"; -import { convertCellIdToCSS } from "oxalis/view/left-border-tabs/mapping_settings_view"; import { interpolateSegmentationLayerAction, createCellAction, @@ -20,6 +19,7 @@ import { getMappingInfoForVolumeTracing, getMaximumBrushSize, getRenderableResolutionForActiveSegmentationTracing, + getSegmentColorAsHSL, hasEditableMapping, } from "oxalis/model/accessors/volumetracing_accessor"; import { getActiveTree } from "oxalis/model/accessors/skeletontracing_accessor"; @@ -52,6 +52,7 @@ import Store, { OxalisState, VolumeTracing } from "oxalis/store"; import features from "features"; import { getInterpolationInfo } from "oxalis/model/sagas/volume/volume_interpolation_saga"; +import { hslaToCSS } from "oxalis/shaders/utils.glsl"; import { clearProofreadingByProducts } from "oxalis/model/actions/proofread_actions"; import { hasAgglomerateMapping } from "oxalis/controller/combinations/segmentation_handlers"; @@ -276,18 +277,25 @@ function VolumeInterpolationButton() { /> ); + const buttonsRender = useCallback( + ([leftButton, rightButton]) => [ + + {React.cloneElement(leftButton as React.ReactElement, { + disabled: isDisabled, + })} + , + rightButton, + ], + [tooltipTitle], + ); + return ( } overlay={menu} onClick={onInterpolateClick} style={{ padding: "0 5px 0 6px" }} - buttonsRender={([leftButton, rightButton]) => [ - - {React.cloneElement(leftButton as React.ReactElement, { isDisabled })} - , - rightButton, - ]} + buttonsRender={buttonsRender} > {React.cloneElement(INTERPOLATION_ICON[interpolationMode], { style: { margin: -4 } })} @@ -371,7 +379,10 @@ function AdditionalSkeletonModesButtons() { ); } -const mapId = (volumeTracing: VolumeTracing, id: number) => { +const mapId = (volumeTracing: VolumeTracing | null | undefined, id: number) => { + if (!volumeTracing) { + return null; + } const { cube } = Model.getSegmentationTracingLayer(volumeTracing.tracingId); return cube.mapId(id); }; @@ -379,20 +390,26 @@ const mapId = (volumeTracing: VolumeTracing, id: number) => { function CreateCellButton() { const volumeTracing = useSelector((state: OxalisState) => getActiveSegmentationTracing(state)); const unmappedActiveCellId = volumeTracing != null ? volumeTracing.activeCellId : 0; - const { mappingStatus, mappingColors } = useSelector((state: OxalisState) => + const { mappingStatus } = useSelector((state: OxalisState) => getMappingInfoForVolumeTracing(state, volumeTracing != null ? volumeTracing.tracingId : null), ); const isMappingEnabled = mappingStatus === MappingStatusEnum.ENABLED; - if (!volumeTracing) { - return null; - } - - const customColors = isMappingEnabled ? mappingColors : null; const activeCellId = isMappingEnabled ? mapId(volumeTracing, unmappedActiveCellId) : unmappedActiveCellId; - const activeCellColor = convertCellIdToCSS(activeCellId, customColors); + + const activeCellColor = useSelector((state: OxalisState) => { + if (!activeCellId) { + return null; + } + return hslaToCSS(getSegmentColorAsHSL(state, activeCellId)); + }); + + if (!activeCellId || !activeCellColor) { + return null; + } + const mappedIdInfo = isMappingEnabled ? ` (currently mapped to ${activeCellId})` : ""; return ( ; }; -// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'typeof MenuItem' is not assignab... Remove this comment to see the full error message -const MenuItemWithMappingActivationConfirmation = withMappingActivationConfirmation(Menu.Item); + +const MenuItemWithMappingActivationConfirmation = withMappingActivationConfirmation< + MenuItemProps, + typeof Menu.Item +>(Menu.Item); function copyIconWithTooltip(value: string | number, title: string) { return ( @@ -785,7 +788,6 @@ function NoNodeContextMenuOptions(props: NoNodeContextMenuProps): JSX.Element { currentConnectomeFile != null ? currentConnectomeFile.mappingName : undefined; const loadSynapsesItem = ( or . + * However, we want non-clickable info rows with a special styling here. + * Note that we must not pass along all props here, since antd will pass + * an eventKey prop to this component. Delegating this to
will + * produce a "React does not recognize the `eventKey` prop on a DOM element" + * warning. + */ + + const { children } = propsWithEventKey; + return
{children}
; +} + function ContextMenuInner(propsWithInputRef: PropsWithRef) { const { inputRef, ...props } = propsWithInputRef; const { @@ -1012,47 +1028,47 @@ function ContextMenuInner(propsWithInputRef: PropsWithRef) { if (maybeClickedNodeId != null && nodeContextMenuTree != null) { infoRows.push( -
+ {/* @ts-expect-error ts-migrate(2339) FIXME: Property 'treeId' does not exist on type 'never'.*/} Node with Id {maybeClickedNodeId} in Tree {nodeContextMenuTree.treeId} -
, + , ); } if (nodeContextMenuNode != null) { infoRows.push( -
+ Position: {nodePositionAsString} {copyIconWithTooltip(nodePositionAsString, "Copy node position")} -
, + , ); } else if (globalPosition != null) { const positionAsString = positionToString(globalPosition); infoRows.push( -
+ Position: {positionAsString} {copyIconWithTooltip(positionAsString, "Copy position")} -
, + , ); } if (distanceToSelection != null) { infoRows.push( -
+ {distanceToSelection[0]} ({distanceToSelection[1]}) to this{" "} {maybeClickedNodeId != null ? "Node" : "Position"} {copyIconWithTooltip(distanceToSelection[0], "Copy the distance")} -
, + , ); } if (segmentIdAtPosition > 0) { infoRows.push( -
+
Segment ID: {`${segmentIdAtPosition}`}{" "} {copyIconWithTooltip(segmentIdAtPosition, "Copy Segment ID")} -
, +
, ); } diff --git a/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx b/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx index af028a3bbe8..6908e959321 100644 --- a/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx +++ b/frontend/javascripts/oxalis/view/left-border-tabs/mapping_settings_view.tsx @@ -3,7 +3,7 @@ import { connect } from "react-redux"; import React from "react"; import debounceRender from "react-debounce-render"; import type { APIDataset, APISegmentationLayer } from "types/api_flow_types"; -import type { OrthoView, Vector3, Vector4 } from "oxalis/constants"; +import type { OrthoView, Vector3 } from "oxalis/constants"; import { MappingStatusEnum } from "oxalis/constants"; import type { OxalisState, Mapping, MappingType, EditableMapping } from "oxalis/store"; import { getMappingsForDatasetLayer, getAgglomeratesForDatasetLayer } from "admin/admin_rest_api"; @@ -21,11 +21,11 @@ import { } from "oxalis/model/actions/settings_actions"; import { SwitchSetting } from "oxalis/view/components/setting_input_views"; import * as Utils from "libs/utils"; -import { jsConvertCellIdToHSLA } from "oxalis/shaders/segmentation.glsl"; import { getEditableMappingForVolumeTracingId, hasEditableMapping, } from "oxalis/model/accessors/volumetracing_accessor"; + const { Option, OptGroup } = Select; type OwnProps = { @@ -68,16 +68,6 @@ type State = { didRefreshMappingList: boolean; }; -const convertHSLAToCSSString = ([h, s, l, a]: Vector4) => - `hsla(${360 * h}, ${100 * s}%, ${100 * l}%, ${a})`; - -export const convertCellIdToCSS = ( - id: number, - customColors: Array | null | undefined, - alpha?: number, -) => - id === 0 ? "transparent" : convertHSLAToCSSString(jsConvertCellIdToHSLA(id, customColors, alpha)); - const needle = "##"; const packMappingNameAndCategory = (mappingName: string, category: MappingType) => diff --git a/frontend/javascripts/oxalis/view/plane_view.ts b/frontend/javascripts/oxalis/view/plane_view.ts index daa9a002f3f..da54bf64ec6 100644 --- a/frontend/javascripts/oxalis/view/plane_view.ts +++ b/frontend/javascripts/oxalis/view/plane_view.ts @@ -113,9 +113,7 @@ class PlaneView { // This prevents the GPU/CPU from constantly // working and keeps your lap cool // ATTENTION: this limits the FPS to 60 FPS (depending on the keypress update frequence) - // @ts-expect-error ts-migrate(2339) FIXME: Property 'needsRerender' does not exist on type '(... Remove this comment to see the full error message if (forceRender || this.needsRerender || window.needsRerender) { - // @ts-expect-error ts-migrate(2339) FIXME: Property 'needsRerender' does not exist on type '(... Remove this comment to see the full error message window.needsRerender = false; const { renderer, scene } = SceneController; this.trigger("render"); diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx index 8110b48410f..4bbd5375692 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx @@ -5,16 +5,16 @@ import { VerticalAlignBottomOutlined, EllipsisOutlined, } from "@ant-design/icons"; -import { List, Tooltip, Dropdown, Menu } from "antd"; -import { useDispatch } from "react-redux"; +import { List, Tooltip, Dropdown, Menu, MenuItemProps } from "antd"; +import { useDispatch, useSelector } from "react-redux"; import Checkbox from "antd/lib/checkbox/Checkbox"; import React from "react"; import classnames from "classnames"; +import * as Utils from "libs/utils"; import type { APISegmentationLayer, APIMeshFile } from "types/api_flow_types"; -import type { Vector3 } from "oxalis/constants"; +import type { Vector3, Vector4 } from "oxalis/constants"; import { formatDateInLocalTimeZone } from "components/formatted_date"; -import { jsConvertCellIdToHSLA } from "oxalis/shaders/segmentation.glsl"; import { triggerIsosurfaceDownloadAction, updateIsosurfaceVisibilityAction, @@ -23,50 +23,61 @@ import { } from "oxalis/model/actions/annotation_actions"; import EditableTextLabel from "oxalis/view/components/editable_text_label"; import { withMappingActivationConfirmation } from "oxalis/view/right-border-tabs/segments_tab/segments_view_helper"; -import type { ActiveMappingInfo, IsosurfaceInformation, Segment } from "oxalis/store"; +import type { ActiveMappingInfo, IsosurfaceInformation, OxalisState, Segment } from "oxalis/store"; import Store from "oxalis/store"; +import { getSegmentColorAsHSL } from "oxalis/model/accessors/volumetracing_accessor"; +import Toast from "libs/toast"; +import { hslaToCSS } from "oxalis/shaders/utils.glsl"; +import { V4 } from "libs/mjs"; -const convertCellIdToCSS = (id: number, mappingColors: ActiveMappingInfo["mappingColors"]) => { - const [h, s, l, a] = jsConvertCellIdToHSLA(id, mappingColors); - return `hsla(${360 * h}, ${100 * s}%, ${100 * l}%, ${a})`; -}; +function ColoredDotIconForSegment({ segmentColorHSLA }: { segmentColorHSLA: Vector4 }) { + const hslaCss = hslaToCSS(segmentColorHSLA); -function getColoredDotIconForSegment( - segmentId: number, - mappingColors: ActiveMappingInfo["mappingColors"], -) { return ( ); } -// @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'typeof MenuItem' is not assignab... Remove this comment to see the full error message -const MenuItemWithMappingActivationConfirmation = withMappingActivationConfirmation(Menu.Item); +const MenuItemWithMappingActivationConfirmation = withMappingActivationConfirmation< + MenuItemProps, + typeof Menu.Item +>(Menu.Item); const getLoadPrecomputedMeshMenuItem = ( segment: Segment, currentMeshFile: APIMeshFile | null | undefined, loadPrecomputedMesh: (arg0: number, arg1: Vector3, arg2: string) => void, - andCloseContextMenu: (_ignore: any) => void, + andCloseContextMenu: (_ignore?: any) => void, layerName: string | null | undefined, mappingInfo: ActiveMappingInfo, ) => { const mappingName = currentMeshFile != null ? currentMeshFile.mappingName : undefined; return ( + key="loadPrecomputedMesh" + onClick={() => { + if (!currentMeshFile) { + return; + } + if (!segment.somePosition) { + Toast.info( + + Cannot load a mesh for this segment, because its position is unknown. + , + ); + andCloseContextMenu(); + return; + } andCloseContextMenu( - // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | undefined' is not assig... Remove this comment to see the full error message loadPrecomputedMesh(segment.id, segment.somePosition, currentMeshFile?.meshFileName), - ) - } - // @ts-expect-error ts-migrate(2322) FIXME: Type '{ children: Element; onClick: () => void; di... Remove this comment to see the full error message + ); + }} disabled={!currentMeshFile} mappingName={mappingName} descriptor="mesh file" @@ -91,12 +102,25 @@ const getComputeMeshAdHocMenuItem = ( segment: Segment, loadAdHocMesh: (arg0: number, arg1: Vector3) => void, isSegmentationLayerVisible: boolean, - andCloseContextMenu: (_ignore: any) => void, + andCloseContextMenu: (_ignore?: any) => void, ) => { const { disabled, title } = getComputeMeshAdHocTooltipInfo(false, isSegmentationLayerVisible); return ( andCloseContextMenu(loadAdHocMesh(segment.id, segment.somePosition))} + key="loadAdHocMesh" + onClick={() => { + if (!segment.somePosition) { + Toast.info( + + Cannot load a mesh for this segment, because its position is unknown. + , + ); + andCloseContextMenu(); + return; + } + + andCloseContextMenu(loadAdHocMesh(segment.id, segment.somePosition)); + }} disabled={disabled} > Compute Mesh (ad hoc) @@ -108,7 +132,7 @@ const getMakeSegmentActiveMenuItem = ( segment: Segment, setActiveCell: (arg0: number, somePosition?: Vector3) => void, activeCellId: number | null | undefined, - andCloseContextMenu: (_ignore: any) => void, + andCloseContextMenu: (_ignore?: any) => void, ) => { const disabled = segment.id === activeCellId; const title = disabled @@ -116,6 +140,7 @@ const getMakeSegmentActiveMenuItem = ( : "Make this the active segment ID."; return ( andCloseContextMenu(setActiveCell(segment.id, segment.somePosition))} disabled={disabled} > @@ -129,7 +154,7 @@ type Props = { mapId: (arg0: number) => number; isJSONMappingEnabled: boolean; mappingInfo: ActiveMappingInfo; - hoveredSegmentId: number | null | undefined; + isHoveredSegmentId: boolean; centeredSegmentId: number | null | undefined; selectedSegmentId: number | null | undefined; activeCellId: number | null | undefined; @@ -296,7 +321,7 @@ function _SegmentListItem({ mapId, isJSONMappingEnabled, mappingInfo, - hoveredSegmentId, + isHoveredSegmentId, centeredSegmentId, selectedSegmentId, activeCellId, @@ -314,13 +339,22 @@ function _SegmentListItem({ loadPrecomputedMesh, currentMeshFile, }: Props) { + const isEditingDisabled = !allowUpdate; + const mappedId = mapId(segment.id); + const segmentColorHSLA = useSelector( + (state: OxalisState) => getSegmentColorAsHSL(state, mappedId), + (a: Vector4, b: Vector4) => V4.isEqual(a, b), + ); + + const segmentColorRGBA = Utils.hslaToRgba(segmentColorHSLA); + if (mappingInfo.hideUnmappedIds && mappedId === 0) { return null; } - const andCloseContextMenu = (_ignore: any) => handleSegmentDropdownMenuVisibility(0, false); + const andCloseContextMenu = (_ignore?: any) => handleSegmentDropdownMenuVisibility(0, false); const createSegmentContextMenu = () => ( @@ -339,6 +373,64 @@ function _SegmentListItem({ andCloseContextMenu, )} {getMakeSegmentActiveMenuItem(segment, setActiveCell, activeCellId, andCloseContextMenu)} + {/* + * Disable the change-color menu if the segment was mapped to another segment, because + * changing the color wouldn't do anything as long as the mapping is still active. + * This is because the id (A) is mapped to another one (B). So, the user would need + * to change the color of B to see the effect for A. + */} + +
+ Change Segment Color + { + if (isEditingDisabled || visibleSegmentationLayer == null) { + return; + } + + let color = Utils.hexToRgb(event.target.value); + color = Utils.map3((component) => component / 255, color); + updateSegment( + segment.id, + { + color: [color[0], color[1], color[2]], + }, + visibleSegmentationLayer.name, + ); + }} + /> +
+
+ + { + if (isEditingDisabled || visibleSegmentationLayer == null) { + return; + } + updateSegment( + segment.id, + { + color: null, + }, + visibleSegmentationLayer.name, + ); + }} + > + Reset Segment Color +
); @@ -366,7 +458,7 @@ function _SegmentListItem({ }} className={classnames("segment-list-item", { "is-selected-cell": segment.id === selectedSegmentId, - "is-hovered-cell": segment.id === hoveredSegmentId, + "is-hovered-cell": isHoveredSegmentId, })} onMouseEnter={() => { setHoveredSegmentId(segment.id); @@ -390,7 +482,7 @@ function _SegmentListItem({ trigger={["contextMenu"]} > - {getColoredDotIconForSegment(mappedId, mappingInfo.mappingColors)} + { }; }; -const mapDispatchToProps = (dispatch: Dispatch): any => ({ +const mapDispatchToProps = (dispatch: Dispatch) => ({ setHoveredSegmentId(segmentId: number | null | undefined) { dispatch(updateTemporarySettingAction("hoveredSegmentId", segmentId)); }, @@ -335,6 +334,15 @@ class SegmentsView extends React.Component { this.setState({ selectedSegmentId: segment.id, }); + + if (!segment.somePosition) { + Toast.info( + + Cannot go to this segment, because its position is unknown. + , + ); + return; + } this.props.setPosition(segment.somePosition); }; @@ -363,8 +371,10 @@ class SegmentsView extends React.Component { defaultOrHigherIndex != null ? defaultOrHigherIndex : datasetResolutionInfo.getClosestExistingIndex(preferredQualityForMeshPrecomputation); - const meshfileResolution = - datasetResolutionInfo.getResolutionByIndexWithFallback(meshfileResolutionIndex); + const meshfileResolution = datasetResolutionInfo.getResolutionByIndexWithFallback( + meshfileResolutionIndex, + null, + ); if (this.props.visibleSegmentationLayer != null) { const job = await startComputeMeshFileJob( @@ -395,7 +405,7 @@ class SegmentsView extends React.Component { } }; - handleMeshFileSelected = async (meshFileName: SelectValue) => { + handleMeshFileSelected = async (meshFileName: string) => { if (this.props.visibleSegmentationLayer != null && meshFileName != null) { this.props.setCurrentMeshFile(this.props.visibleSegmentationLayer.name, meshFileName); } @@ -633,7 +643,7 @@ class SegmentsView extends React.Component { isosurface={this.props.isosurfaces[segment.id]} isJSONMappingEnabled={this.props.isJSONMappingEnabled} mappingInfo={this.props.mappingInfo} - hoveredSegmentId={this.props.hoveredSegmentId} + isHoveredSegmentId={this.props.hoveredSegmentId === segment.id} activeCellId={this.props.activeCellId} setHoveredSegmentId={this.props.setHoveredSegmentId} allowUpdate={this.props.allowUpdate} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 0a7a5911515..3f41b4b41d8 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -459,7 +459,7 @@ class TreeHierarchyView extends React.PureComponent { cursor: "pointer", }} />{" "} - Select Tree Color + Change Tree Color value * 255, tree.color))} diff --git a/frontend/javascripts/router.tsx b/frontend/javascripts/router.tsx index 90fc1cb5e86..60b7edb9eb3 100644 --- a/frontend/javascripts/router.tsx +++ b/frontend/javascripts/router.tsx @@ -366,7 +366,6 @@ class ReactRouter extends React.Component { onComplete={() => window.location.replace(`${window.location.origin}/dashboard/datasets`) } - // @ts-expect-error ts-migrate(2339) FIXME: Property 'history' does not exist on type '(Window... Remove this comment to see the full error message onCancel={() => window.history.back()} /> )} @@ -381,9 +380,7 @@ class ReactRouter extends React.Component { name: match.params.datasetName || "", owningOrganization: match.params.organizationName || "", }} - // @ts-expect-error ts-migrate(2339) FIXME: Property 'history' does not exist on type '(Window... Remove this comment to see the full error message onComplete={() => window.history.back()} - // @ts-expect-error ts-migrate(2339) FIXME: Property 'history' does not exist on type '(Window... Remove this comment to see the full error message onCancel={() => window.history.back()} /> )} diff --git a/frontend/javascripts/test/mocks/globals.mock.ts b/frontend/javascripts/test/mocks/globals.mock.ts new file mode 100644 index 00000000000..95e72ab6222 --- /dev/null +++ b/frontend/javascripts/test/mocks/globals.mock.ts @@ -0,0 +1,4 @@ +// @ts-ignore +global.performance = { + now: () => Date.now(), +}; diff --git a/frontend/javascripts/test/mocks/updatable_texture.mock.ts b/frontend/javascripts/test/mocks/updatable_texture.mock.ts new file mode 100644 index 00000000000..cbc68d99bc4 --- /dev/null +++ b/frontend/javascripts/test/mocks/updatable_texture.mock.ts @@ -0,0 +1,49 @@ +import * as THREE from "three"; +import mock from "mock-require"; + +/* + * Note that RGB textures are currently not tested in this spec. + * If tests were added, the following Map would not be sufficient, anymore, + * since RGBAFormat is also used for 3 channels which would make the key not unique. + */ +const formatToChannelCount = new Map([ + [THREE.RedFormat, 1], + [THREE.RedIntegerFormat, 1], + [THREE.RGFormat, 2], + [THREE.RGIntegerFormat, 2], + [THREE.RGBAFormat, 4], + [THREE.RGBAIntegerFormat, 4], +]); + +mock( + "libs/UpdatableTexture", + class UpdatableTexture { + texture: Uint8Array = new Uint8Array(); + width: number = 0; + height: number = 0; + channelCount: number; + + constructor(_width: number, _height: number, format: any) { + this.channelCount = formatToChannelCount.get(format) || 0; + if (this.channelCount === 0) { + throw new Error("Format could not be converted to channel count"); + } + } + + update(src: Float32Array | Uint8Array, x: number, y: number, _width: number, _height: number) { + this.texture.set(src, y * this.width + x); + } + + setRenderer() {} + + setSize(width: number, height: number) { + this.texture = new Uint8Array(width * height * this.channelCount); + this.width = width; + this.height = height; + } + + isInitialized() { + return true; + } + }, +); diff --git a/frontend/javascripts/test/model/binary/data_rendering_logic.spec.ts b/frontend/javascripts/test/model/binary/data_rendering_logic.spec.ts index 8c1a7397124..bcbeee92667 100644 --- a/frontend/javascripts/test/model/binary/data_rendering_logic.spec.ts +++ b/frontend/javascripts/test/model/binary/data_rendering_logic.spec.ts @@ -102,14 +102,8 @@ const volumeLayer1 = { const getByteCount = (layer) => layer.byteCount; -function testSupportFlags( - t, - supportFlags, - expectedMaximumLayerCountToRender, - expectedMappingSupport, -) { +function testSupportFlags(t, supportFlags, expectedMaximumLayerCountToRender) { t.is(supportFlags.maximumLayerCountToRender, expectedMaximumLayerCountToRender); - t.is(supportFlags.isMappingSupported, expectedMappingSupport); } function computeDataTexturesSetupCurried(spec, hasSegmentation): any { @@ -131,34 +125,27 @@ test("Basic support (no segmentation): all specs", (t) => { [betterSpecs, 16], ]) { const computeDataTexturesSetupPartial = computeDataTexturesSetupCurried(spec, false); - testSupportFlags( - t, - computeDataTexturesSetupPartial([grayscaleLayer1]), - expectedLayerCount, - true, - ); + testSupportFlags(t, computeDataTexturesSetupPartial([grayscaleLayer1]), expectedLayerCount); testSupportFlags( t, computeDataTexturesSetupPartial([grayscaleLayer1, grayscaleLayer2]), expectedLayerCount, - true, ); testSupportFlags( t, computeDataTexturesSetupPartial([grayscaleLayer1, grayscaleLayer2, grayscaleLayer3]), expectedLayerCount, - true, ); } }); test("Basic support + volume: min specs", (t) => { const computeDataTexturesSetupPartial = computeDataTexturesSetupCurried(minSpecs, true); - testSupportFlags(t, computeDataTexturesSetupPartial([grayscaleLayer1, volumeLayer1]), 2, false); + testSupportFlags(t, computeDataTexturesSetupPartial([grayscaleLayer1, grayscaleLayer2]), 2); + testSupportFlags(t, computeDataTexturesSetupPartial([grayscaleLayer1, volumeLayer1]), 1); testSupportFlags( t, computeDataTexturesSetupPartial([grayscaleLayer1, grayscaleLayer2, volumeLayer1]), - 2, - false, + 1, ); testSupportFlags( t, @@ -168,18 +155,16 @@ test("Basic support + volume: min specs", (t) => { grayscaleLayer3, volumeLayer1, ]), - 2, - false, + 1, ); }); test("Basic support + volume: mid specs", (t) => { const computeDataTexturesSetupPartial = computeDataTexturesSetupCurried(midSpecs, true); - testSupportFlags(t, computeDataTexturesSetupPartial([grayscaleLayer1, volumeLayer1]), 8, true); + testSupportFlags(t, computeDataTexturesSetupPartial([grayscaleLayer1, volumeLayer1]), 6, true); testSupportFlags( t, computeDataTexturesSetupPartial([grayscaleLayer1, grayscaleLayer2, volumeLayer1]), - 8, - true, + 6, ); testSupportFlags( t, @@ -189,7 +174,6 @@ test("Basic support + volume: mid specs", (t) => { grayscaleLayer3, volumeLayer1, ]), - 8, - true, + 6, ); }); diff --git a/frontend/javascripts/test/model/cuckoo_table.spec.ts b/frontend/javascripts/test/model/cuckoo_table.spec.ts new file mode 100644 index 00000000000..ea7bf8b442b --- /dev/null +++ b/frontend/javascripts/test/model/cuckoo_table.spec.ts @@ -0,0 +1,117 @@ +import mock from "mock-require"; +import test, { ExecutionContext } from "ava"; +import _ from "lodash"; +import { Vector3 } from "oxalis/constants"; + +import "test/mocks/globals.mock"; +import "test/mocks/updatable_texture.mock"; + +type Entry = [number, Vector3]; + +const { CuckooTable } = mock.reRequire("oxalis/model/bucket_data_handling/cuckoo_table"); + +function generateRandomEntry(): [number, Vector3] { + return [ + Math.floor(Math.random() * 2 ** 32), + [ + Math.floor(Math.random() * 1000), + Math.floor(Math.random() * 1000), + Math.floor(Math.random() * 1000), + ], + ]; +} + +function generateRandomEntrySet() { + const count = 1600; + const set = new Set(); + const entries = []; + for (let i = 0; i < count; i++) { + const entry = generateRandomEntry(); + const entryKey = entry[0]; + if (set.has(entryKey)) { + i--; + continue; + } + set.add(entryKey); + entries.push(entry); + } + return entries; +} + +function isValueEqual(t: ExecutionContext, val1: Vector3, val2: Vector3) { + if (!(val1[0] === val2[0] && val1[1] === val2[1] && val1[2] === val2[2])) { + throw new Error(`${val1} !== ${val2}`); + } + + t.true(val1[0] === val2[0]); + t.true(val1[1] === val2[1]); + t.true(val1[2] === val2[2]); +} + +test.serial("CuckooTable: Basic", (t) => { + const entries = generateRandomEntrySet(); + const ct = CuckooTable.fromCapacity(entries.length); + + for (const entry of entries) { + ct.set(entry[0], entry[1]); + const readValue = ct.get(entry[0]); + + isValueEqual(t, entry[1], readValue); + } + + // Check that all previously set items are still + // intact. + for (const innerEntry of entries) { + isValueEqual(t, innerEntry[1], ct.get(innerEntry[0])); + } +}); + +test.serial("CuckooTable: Speed should be alright", (t) => { + const RUNS = 100; + const hashSets = _.range(RUNS).map(() => generateRandomEntrySet()); + const tables = _.range(RUNS).map(() => CuckooTable.fromCapacity(hashSets[0].length)); + + const durations = []; + for (let idx = 0; idx < RUNS; idx++) { + const ct = tables[idx]; + const entries = hashSets[idx]; + for (const entry of entries) { + const then = performance.now(); + ct.set(entry[0], entry[1]); + const now = performance.now(); + durations.push(now - then); + } + } + + t.true(_.mean(durations) < 0.1); +}); + +test.serial("CuckooTable: Repeated sets should work", (t) => { + const ct = CuckooTable.fromCapacity(1); + + // This is a regression test for a bug which resulted in the + // same key being multiple times in the table. Due to the random + // usage of seeds, the bug did not always occur. Therefore, + // the following loop iterates 1000th times to be extra thorough. + for (let n = 0; n < 1000; n++) { + for (let _idx = 0; _idx < ct.entryCapacity; _idx++) { + const entry: Entry = [1, [2, 3, n]]; + ct.set(entry[0], entry[1]); + const readValue = ct.get(entry[0]); + isValueEqual(t, entry[1], readValue); + } + } +}); + +test.serial("CuckooTable: Should throw error when exceeding capacity", (t) => { + const ct = CuckooTable.fromCapacity(1); + + t.throws(() => { + for (let _idx = 0; _idx < ct.entryCapacity + 1; _idx++) { + const entry: Entry = [_idx + 1, [2, 3, 4]]; + ct.set(entry[0], entry[1]); + const readValue = ct.get(entry[0]); + isValueEqual(t, entry[1], readValue); + } + }); +}); diff --git a/frontend/javascripts/test/model/texture_bucket_manager.spec.ts b/frontend/javascripts/test/model/texture_bucket_manager.spec.ts index c9128ab3aba..5fcf9c32c16 100644 --- a/frontend/javascripts/test/model/texture_bucket_manager.spec.ts +++ b/frontend/javascripts/test/model/texture_bucket_manager.spec.ts @@ -1,61 +1,9 @@ -import * as THREE from "three"; import mock from "mock-require"; import test, { ExecutionContext } from "ava"; import { Vector4 } from "oxalis/constants"; -/* - * Note that RGB textures are currently not tested in this spec. - * If tests were added, the following Map would not be sufficient, anymore, - * since RGBAFormat is also used for 3 channels which would make the key not unique. - */ -const formatToChannelCount = new Map([ - [THREE.RedFormat, 1], - [THREE.RGFormat, 2], - [THREE.RGBAFormat, 4], -]); - -// @ts-ignore -global.performance = { - now: () => Date.now(), -}; -mock("libs/window", { - requestAnimationFrame: () => {}, - document: { - getElementById: () => null, - }, -}); -mock( - "libs/UpdatableTexture", - class UpdatableTexture { - texture: Uint8Array = new Uint8Array(); - width: number = 0; - height: number = 0; - channelCount: number; - - constructor(_width: number, _height: number, format: any) { - this.channelCount = formatToChannelCount.get(format) || 0; - if (this.channelCount === 0) { - throw new Error("Format could not be converted to channel count"); - } - } - - update(src: Float32Array | Uint8Array, x: number, y: number, _width: number, _height: number) { - this.texture.set(src, y * this.width + x); - } - - setRenderer() {} - - setSize(width: number, height: number) { - this.texture = new Uint8Array(width * height * this.channelCount); - this.width = width; - this.height = height; - } - - isInitialized() { - return true; - } - }, -); +import "test/mocks/globals.mock"; +import "test/mocks/updatable_texture.mock"; const temporalBucketManagerMock = { addBucket: () => {}, diff --git a/frontend/javascripts/test/shaders/shader_syntax.spec.ts b/frontend/javascripts/test/shaders/shader_syntax.spec.ts index 24badcdcfe2..2c9e8a24e6c 100644 --- a/frontend/javascripts/test/shaders/shader_syntax.spec.ts +++ b/frontend/javascripts/test/shaders/shader_syntax.spec.ts @@ -28,7 +28,6 @@ test("Shader syntax: Ortho Mode", (t: ExecutionContext) => { color_layer_2: 4.0, }, segmentationLayerNames: [], - isMappingSupported: true, dataTextureCountPerLayer: 3, resolutions, datasetScale: [1, 1, 1], @@ -55,7 +54,6 @@ test("Shader syntax: Ortho Mode + Segmentation - Mapping", (t: ExecutionContext< segmentationLayer: 1.0, }, segmentationLayerNames: ["segmentationLayer"], - isMappingSupported: false, dataTextureCountPerLayer: 3, resolutions, datasetScale: [1, 1, 1], @@ -75,13 +73,13 @@ test("Shader syntax: Ortho Mode + Segmentation + Mapping", (t: ExecutionContext< segmentationLayer: 1.0, }, segmentationLayerNames: ["segmentationLayer"], - isMappingSupported: true, dataTextureCountPerLayer: 3, resolutions, datasetScale: [1, 1, 1], isOrthogonal: true, lookupTextureWidth: DEFAULT_LOOK_UP_TEXTURE_WIDTH, }); + parser.parse(code); t.true(t.context.warningEmittedCount === 0); }); @@ -94,7 +92,6 @@ test("Shader syntax: Arbitrary Mode (no segmentation available)", (t: ExecutionC color_layer_2: 4.0, }, segmentationLayerNames: [], - isMappingSupported: true, dataTextureCountPerLayer: 3, resolutions, datasetScale: [1, 1, 1], @@ -114,7 +111,6 @@ test("Shader syntax: Arbitrary Mode (segmentation available)", (t: ExecutionCont segmentationLayer: 1.0, }, segmentationLayerNames: ["segmentationLayer"], - isMappingSupported: true, dataTextureCountPerLayer: 3, resolutions, datasetScale: [1, 1, 1], @@ -133,7 +129,6 @@ test("Shader syntax: Ortho Mode (rgb and float layer)", (t: ExecutionContext ElementClassProto} -import com.scalableminds.webknossos.datastore.geometry.{BoundingBoxProto, Vec3IntProto, Vec3DoubleProto} +import com.scalableminds.webknossos.datastore.geometry.{BoundingBoxProto, ColorProto, Vec3DoubleProto, Vec3IntProto} import com.scalableminds.webknossos.datastore.models.datasource.ElementClass trait ProtoGeometryImplicits { @@ -33,4 +33,10 @@ trait ProtoGeometryImplicits { implicit def elementClassFromProto(ec: ElementClassProto): ElementClass.Value = ElementClass.guessFromBytesPerElement(ec.value).getOrElse(ElementClass.uint32) + implicit def colorToProto(c: com.scalableminds.util.image.Color): ColorProto = + ColorProto(c.r, c.g, c.b, c.a) + + implicit def colorOptToProto(cOpt: Option[com.scalableminds.util.image.Color]): Option[ColorProto] = + cOpt.map(colorToProto) + } diff --git a/webknossos-datastore/proto/VolumeTracing.proto b/webknossos-datastore/proto/VolumeTracing.proto index e99eb211326..820d04b5472 100644 --- a/webknossos-datastore/proto/VolumeTracing.proto +++ b/webknossos-datastore/proto/VolumeTracing.proto @@ -9,6 +9,7 @@ message Segment { optional Vec3IntProto anchorPosition = 2; optional string name = 3; optional int64 creationTime = 4; + optional ColorProto color = 5; } message VolumeTracing { diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/NamedBoundingBox.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/NamedBoundingBox.scala index 0d31b615de0..526af171b67 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/NamedBoundingBox.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/NamedBoundingBox.scala @@ -14,7 +14,7 @@ case class NamedBoundingBox(id: Int, boundingBox: BoundingBox) extends ProtoGeometryImplicits with SkeletonUpdateActionHelper { - def toProto: ProtoBoundingBox = ProtoBoundingBox(id, name, isVisible, convertColorOpt(color), boundingBox) + def toProto: ProtoBoundingBox = ProtoBoundingBox(id, name, isVisible, colorOptToProto(color), boundingBox) } object NamedBoundingBox { implicit val jsonFormat: OFormat[NamedBoundingBox] = Json.format[NamedBoundingBox] } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActionHelper.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActionHelper.scala index 08dc831c0b2..517eea55b07 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActionHelper.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActionHelper.scala @@ -1,9 +1,9 @@ package com.scalableminds.webknossos.tracingstore.tracings.skeleton.updating import com.scalableminds.webknossos.datastore.SkeletonTracing._ -import com.scalableminds.webknossos.datastore.geometry.ColorProto +import com.scalableminds.webknossos.datastore.helpers.ProtoGeometryImplicits -trait SkeletonUpdateActionHelper { +trait SkeletonUpdateActionHelper extends ProtoGeometryImplicits { protected def mapTrees(tracing: SkeletonTracing, treeId: Int, transformTree: Tree => Tree): Seq[Tree] = tracing.trees.map((tree: Tree) => if (tree.treeId == treeId) transformTree(tree) else tree) @@ -16,17 +16,11 @@ trait SkeletonUpdateActionHelper { .find(_.treeId == treeId) .getOrElse(throw new NoSuchElementException("Tracing does not contain tree with requested id " + treeId)) - protected def convertColor(aColor: com.scalableminds.util.image.Color): ColorProto = - ColorProto(aColor.r, aColor.g, aColor.b, aColor.a) protected def convertBranchPoint(aBranchPoint: UpdateActionBranchPoint): BranchPoint = BranchPoint(aBranchPoint.nodeId, aBranchPoint.timestamp) protected def convertComment(aComment: UpdateActionComment): Comment = Comment(aComment.nodeId, aComment.content) - protected def convertColorOpt(aColorOpt: Option[com.scalableminds.util.image.Color]): Option[ColorProto] = - aColorOpt match { - case Some(aColor) => Some(convertColor(aColor)) - case None => None - } + protected def convertTreeGroup(aTreeGroup: UpdateActionTreeGroup): TreeGroup = TreeGroup(aTreeGroup.name, aTreeGroup.groupId, aTreeGroup.children.map(convertTreeGroup)) } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala index 5f908d300c4..5fcc14f18bf 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala @@ -24,7 +24,7 @@ case class CreateTreeSkeletonAction(id: Int, val newTree = Tree(id, Nil, Nil, - convertColorOpt(color), + colorOptToProto(color), branchPoints.map(convertBranchPoint), comments.map(convertComment), name, @@ -71,7 +71,7 @@ case class UpdateTreeSkeletonAction(id: Int, override def applyOn(tracing: SkeletonTracing): SkeletonTracing = { def treeTransform(tree: Tree) = tree.copy( - color = if (color.isDefined) convertColorOpt(color) else tree.color, + color = colorOptToProto(color).orElse(tree.color), treeId = updatedId.getOrElse(tree.treeId), branchPoints = branchPoints.map(convertBranchPoint), comments = comments.map(convertComment), diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala index 99f40aa39af..a983adca8d7 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala @@ -209,6 +209,7 @@ object UpdateTdCamera { case class CreateSegmentVolumeAction(id: Long, anchorPosition: Option[Vec3Int], name: Option[String], + color: Option[com.scalableminds.util.image.Color], creationTime: Option[Long], actionTimestamp: Option[Long] = None, actionAuthorId: Option[String] = None) @@ -224,7 +225,7 @@ case class CreateSegmentVolumeAction(id: Long, CompactVolumeUpdateAction("createSegment", actionTimestamp, actionAuthorId, Json.obj("id" -> id)) override def applyOn(tracing: VolumeTracing): VolumeTracing = { - val newSegment = Segment(id, anchorPosition.map(vec3IntToProto), name, creationTime) + val newSegment = Segment(id, anchorPosition.map(vec3IntToProto), name, creationTime, colorOptToProto(color)) tracing.addSegments(newSegment) } } @@ -236,6 +237,7 @@ object CreateSegmentVolumeAction { case class UpdateSegmentVolumeAction(id: Long, anchorPosition: Option[Vec3Int], name: Option[String], + color: Option[com.scalableminds.util.image.Color], creationTime: Option[Long], actionTimestamp: Option[Long] = None, actionAuthorId: Option[String] = None) @@ -256,7 +258,8 @@ case class UpdateSegmentVolumeAction(id: Long, segment.copy( anchorPosition = anchorPosition.map(vec3IntToProto), name = name, - creationTime = creationTime + creationTime = creationTime, + color = colorOptToProto(color) ) tracing.withSegments(mapSegments(tracing, id, segmentTransform)) }