From 6ac57c60660f9126d5bd6e5821fef1e9c836e926 Mon Sep 17 00:00:00 2001 From: Florian M Date: Tue, 16 Sep 2025 15:44:44 +0200 Subject: [PATCH 01/13] WIP calculate surface area of segments --- .../controllers/DSMeshController.scala | 49 +++++++++++++++++++ .../conf/datastore.latest.routes | 1 + 2 files changed, 50 insertions(+) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala index 140d5099f4..ca96991fe7 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala @@ -14,6 +14,7 @@ import com.scalableminds.webknossos.datastore.services.mesh.{ import play.api.libs.json.Json import play.api.mvc.{Action, AnyContent, PlayBodyParsers} +import java.nio.{ByteBuffer, ByteOrder} import scala.concurrent.ExecutionContext class DSMeshController @Inject()( @@ -95,4 +96,52 @@ class DSMeshController @Inject()( } yield Ok(data) } } + + def fullMeshSurfaceArea(datasetId: ObjectId, dataLayerName: String): Action[FullMeshRequest] = + Action.async(validateJson[FullMeshRequest]) { implicit request => + accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readDataset(datasetId)) { + for { + (dataSource, dataLayer) <- datasetCache.getWithLayer(datasetId, dataLayerName) ~> NOT_FOUND + data: Array[Byte] <- fullMeshService.loadFor(dataSource, dataLayer, request.body) ?~> "mesh.file.loadChunk.failed" + dataBuffer = ByteBuffer.wrap(data) + _ = dataBuffer.order(ByteOrder.LITTLE_ENDIAN) + numberOfTriangles = dataBuffer.getInt(80) + surface = surfaceFromStlBuffer(dataBuffer, numberOfTriangles) + } yield Ok(s"$numberOfTriangles triangles, surface $surface") + } + } + + private def surfaceFromStlBuffer(dataBuffer: ByteBuffer, numberOfTriangles: Int) = { + val normalOffset = 12 + var surfaceSum = 0.0f + val headerOffset = 84 + for (triangleIndex <- 0 until numberOfTriangles) { + val v1x = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset) + val v1y = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 0) + val v1z = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 1) + val v2x = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 2) + val v2y = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 3) + val v2z = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 4) + val v3x = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 5) + val v3y = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 6) + val v3z = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 7) + + val vec1x = v2x - v1x + val vec1y = v2y - v1y + val vec1z = v2z - v1z + val vec2x = v3x - v1x + val vec2y = v3y - v1y + val vec2z = v3z - v1z + + val crossx = vec1y * vec2z - vec1z * vec2y + val crossy = vec1z * vec2x - vec1x * vec2z + val crossz = vec1x * vec2y - vec1y * vec2x + + val magnitude = Math.sqrt(crossx * crossx + crossy * crossy + crossz * crossz).toFloat + + surfaceSum = surfaceSum + (magnitude / 2.0f) + } + surfaceSum + } + } diff --git a/webknossos-datastore/conf/datastore.latest.routes b/webknossos-datastore/conf/datastore.latest.routes index fbbefa48cc..979859b9f9 100644 --- a/webknossos-datastore/conf/datastore.latest.routes +++ b/webknossos-datastore/conf/datastore.latest.routes @@ -81,6 +81,7 @@ GET /datasets/:datasetId/layers/:dataLayerName/meshes POST /datasets/:datasetId/layers/:dataLayerName/meshes/chunks @com.scalableminds.webknossos.datastore.controllers.DSMeshController.listMeshChunksForSegment(datasetId: ObjectId, dataLayerName: String, targetMappingName: Option[String], editableMappingTracingId: Option[String]) POST /datasets/:datasetId/layers/:dataLayerName/meshes/chunks/data @com.scalableminds.webknossos.datastore.controllers.DSMeshController.readMeshChunk(datasetId: ObjectId, dataLayerName: String) POST /datasets/:datasetId/layers/:dataLayerName/meshes/fullMesh.stl @com.scalableminds.webknossos.datastore.controllers.DSMeshController.loadFullMeshStl(datasetId: ObjectId, dataLayerName: String) +POST /datasets/:datasetId/layers/:dataLayerName/meshes/fullMeshSurface @com.scalableminds.webknossos.datastore.controllers.DSMeshController.fullMeshSurfaceArea(datasetId: ObjectId, dataLayerName: String) # Connectome files GET /datasets/:datasetId/layers/:dataLayerName/connectomes @com.scalableminds.webknossos.datastore.controllers.DataSourceController.listConnectomeFiles(datasetId: ObjectId, dataLayerName: String) From 61c71a2a9bb6b4667b90454465ba931fb82ac447 Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 17 Sep 2025 11:49:05 +0200 Subject: [PATCH 02/13] fix triangle offset --- .../controllers/DSMeshController.scala | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala index ca96991fe7..adccbf96ac 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala @@ -115,16 +115,18 @@ class DSMeshController @Inject()( val normalOffset = 12 var surfaceSum = 0.0f val headerOffset = 84 + val bytesPerTriangle = 50 for (triangleIndex <- 0 until numberOfTriangles) { - val v1x = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset) - val v1y = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 0) - val v1z = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 1) - val v2x = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 2) - val v2y = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 3) - val v2z = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 4) - val v3x = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 5) - val v3y = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 6) - val v3z = dataBuffer.getFloat(headerOffset + triangleIndex + normalOffset + 4 * 7) + val triangleVerticesOffset = headerOffset + triangleIndex * bytesPerTriangle + normalOffset + val v1x = dataBuffer.getFloat(triangleVerticesOffset) + val v1y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 0) + val v1z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 1) + val v2x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 2) + val v2y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 3) + val v2z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 4) + val v3x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 5) + val v3y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 6) + val v3z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 7) val vec1x = v2x - v1x val vec1y = v2y - v1y From 40a8ce4a947261f25b09c887c06a4bdaa060bec3 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 29 Sep 2025 16:21:20 +0200 Subject: [PATCH 03/13] fix vertex index offset --- .../controllers/DSMeshController.scala | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala index adccbf96ac..8a5c13ab24 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala @@ -113,20 +113,20 @@ class DSMeshController @Inject()( private def surfaceFromStlBuffer(dataBuffer: ByteBuffer, numberOfTriangles: Int) = { val normalOffset = 12 - var surfaceSum = 0.0f + var surfaceSumMutable = 0.0f val headerOffset = 84 val bytesPerTriangle = 50 for (triangleIndex <- 0 until numberOfTriangles) { val triangleVerticesOffset = headerOffset + triangleIndex * bytesPerTriangle + normalOffset - val v1x = dataBuffer.getFloat(triangleVerticesOffset) - val v1y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 0) - val v1z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 1) - val v2x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 2) - val v2y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 3) - val v2z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 4) - val v3x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 5) - val v3y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 6) - val v3z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 7) + val v1x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 0) + val v1y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 1) + val v1z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 2) + val v2x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 3) + val v2y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 4) + val v2z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 5) + val v3x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 6) + val v3y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 7) + val v3z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 8) val vec1x = v2x - v1x val vec1y = v2y - v1y @@ -141,9 +141,9 @@ class DSMeshController @Inject()( val magnitude = Math.sqrt(crossx * crossx + crossy * crossy + crossz * crossz).toFloat - surfaceSum = surfaceSum + (magnitude / 2.0f) + surfaceSumMutable = surfaceSumMutable + (magnitude / 2.0f) } - surfaceSum + surfaceSumMutable } } From dbd333517aaddde1cf21ab549954c82913fc0721 Mon Sep 17 00:00:00 2001 From: Florian M Date: Tue, 30 Sep 2025 09:25:45 +0200 Subject: [PATCH 04/13] move to segmentStatistics routes --- conf/messages | 1 + .../controllers/DSMeshController.scala | 50 ------------------- .../controllers/DataSourceController.scala | 34 ++++++++++++- .../services/mesh/FullMeshHelper.scala | 39 +++++++++++++++ .../conf/datastore.latest.routes | 2 +- .../controllers/VolumeTracingController.scala | 30 ++++++++++- .../conf/tracingstore.latest.routes | 1 + 7 files changed, 104 insertions(+), 53 deletions(-) diff --git a/conf/messages b/conf/messages index 9c5667f0a8..96bf16c1ad 100644 --- a/conf/messages +++ b/conf/messages @@ -276,6 +276,7 @@ mesh.file.lookup.failed=Failed to look up mesh file “{0}” mesh.file.readVersion.failed=Failed to read format version from file “{0}” mesh.file.readMappingName.failed=Failed to read mapping name from mesh file “{0}” mesh.meshFileName.required=Trying to load mesh from mesh file, but mesh file name was not supplied. +mesh.loadFull.failed=Failed to load full segment mesh. segmentIndexFile.notFound=Could not find requested segment index file diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala index 8a5c13ab24..dabf13b4f7 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala @@ -14,7 +14,6 @@ import com.scalableminds.webknossos.datastore.services.mesh.{ import play.api.libs.json.Json import play.api.mvc.{Action, AnyContent, PlayBodyParsers} -import java.nio.{ByteBuffer, ByteOrder} import scala.concurrent.ExecutionContext class DSMeshController @Inject()( @@ -97,53 +96,4 @@ class DSMeshController @Inject()( } } - def fullMeshSurfaceArea(datasetId: ObjectId, dataLayerName: String): Action[FullMeshRequest] = - Action.async(validateJson[FullMeshRequest]) { implicit request => - accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readDataset(datasetId)) { - for { - (dataSource, dataLayer) <- datasetCache.getWithLayer(datasetId, dataLayerName) ~> NOT_FOUND - data: Array[Byte] <- fullMeshService.loadFor(dataSource, dataLayer, request.body) ?~> "mesh.file.loadChunk.failed" - dataBuffer = ByteBuffer.wrap(data) - _ = dataBuffer.order(ByteOrder.LITTLE_ENDIAN) - numberOfTriangles = dataBuffer.getInt(80) - surface = surfaceFromStlBuffer(dataBuffer, numberOfTriangles) - } yield Ok(s"$numberOfTriangles triangles, surface $surface") - } - } - - private def surfaceFromStlBuffer(dataBuffer: ByteBuffer, numberOfTriangles: Int) = { - val normalOffset = 12 - var surfaceSumMutable = 0.0f - val headerOffset = 84 - val bytesPerTriangle = 50 - for (triangleIndex <- 0 until numberOfTriangles) { - val triangleVerticesOffset = headerOffset + triangleIndex * bytesPerTriangle + normalOffset - val v1x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 0) - val v1y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 1) - val v1z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 2) - val v2x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 3) - val v2y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 4) - val v2z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 5) - val v3x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 6) - val v3y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 7) - val v3z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 8) - - val vec1x = v2x - v1x - val vec1y = v2y - v1y - val vec1z = v2z - v1z - val vec2x = v3x - v1x - val vec2y = v3y - v1y - val vec2z = v3z - v1z - - val crossx = vec1y * vec2z - vec1z * vec2y - val crossy = vec1z * vec2x - vec1x * vec2z - val crossz = vec1x * vec2y - vec1y * vec2x - - val magnitude = Math.sqrt(crossx * crossx + crossy * crossy + crossz * crossz).toFloat - - surfaceSumMutable = surfaceSumMutable + (magnitude / 2.0f) - } - surfaceSumMutable - } - } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala index f69c220690..dc06f069e6 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala @@ -23,7 +23,12 @@ import com.scalableminds.webknossos.datastore.helpers.{ import com.scalableminds.webknossos.datastore.models.datasource.{DataLayer, DataSource, UsableDataSource} import com.scalableminds.webknossos.datastore.services._ import com.scalableminds.webknossos.datastore.services.connectome.ConnectomeFileService -import com.scalableminds.webknossos.datastore.services.mesh.{MeshFileService, MeshMappingHelper} +import com.scalableminds.webknossos.datastore.services.mesh.{ + DSFullMeshService, + FullMeshRequest, + MeshFileService, + MeshMappingHelper +} import com.scalableminds.webknossos.datastore.services.segmentindex.SegmentIndexFileService import com.scalableminds.webknossos.datastore.services.uploading._ import com.scalableminds.webknossos.datastore.storage.RemoteSourceDescriptorService @@ -65,6 +70,7 @@ class DataSourceController @Inject()( storageUsageService: DSUsedStorageService, datasetErrorLoggingService: DSDatasetErrorLoggingService, exploreRemoteLayerService: ExploreRemoteLayerService, + fullMeshService: DSFullMeshService, uploadService: UploadService, meshFileService: MeshFileService, remoteSourceDescriptorService: RemoteSourceDescriptorService, @@ -610,6 +616,32 @@ class DataSourceController @Inject()( } } + def getSegmentSurfaceArea(datasetId: ObjectId, dataLayerName: String): Action[SegmentStatisticsParameters] = + Action.async(validateJson[SegmentStatisticsParameters]) { implicit request => + accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readDataset(datasetId)) { + for { + (dataSource, dataLayer) <- datasetCache.getWithLayer(datasetId, dataLayerName) ~> NOT_FOUND + surfaceAreas <- Fox.serialCombined(request.body.segmentIds) { segmentId => + val fullMeshRequest = FullMeshRequest( + meshFileName = None, + lod = None, + segmentId = segmentId, + mappingName = request.body.mappingName, + mappingType = request.body.mappingName.map(_ => "HDF5"), + editableMappingTracingId = None, + mag = Some(request.body.mag), + seedPosition = None, + additionalCoordinates = request.body.additionalCoordinates, + ) + for { + data: Array[Byte] <- fullMeshService.loadFor(dataSource, dataLayer, fullMeshRequest) ?~> "mesh.loadFull.failed" + surfaceArea <- fullMeshService.surfaceAreaFromStlBytes(data).toFox + } yield surfaceArea + } + } yield Ok(Json.toJson(surfaceAreas)) + } + } + // Called directly by wk side def exploreRemoteDataset(): Action[ExploreRemoteDatasetRequest] = Action.async(validateJson[ExploreRemoteDatasetRequest]) { implicit request => diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/FullMeshHelper.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/FullMeshHelper.scala index a66388f5d3..a66a982d70 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/FullMeshHelper.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/services/mesh/FullMeshHelper.scala @@ -2,6 +2,8 @@ package com.scalableminds.webknossos.datastore.services.mesh import com.scalableminds.util.geometry.{Vec3Float, Vec3Int} import com.scalableminds.util.time.Instant +import com.scalableminds.util.tools.Box +import com.scalableminds.util.tools.Box.tryo import com.scalableminds.webknossos.datastore.draco.NativeDracoToStlConverter import com.scalableminds.webknossos.datastore.models.VoxelPosition import com.typesafe.scalalogging.LazyLogging @@ -73,4 +75,41 @@ trait FullMeshHelper extends LazyLogging { protected def logMeshingDuration(before: Instant, label: String, lengthBytes: Int): Unit = Instant.logSince(before, s"Served $lengthBytes-byte STL mesh via $label,", logger) + def surfaceAreaFromStlBytes(stlBytes: Array[Byte]): Box[Float] = tryo { + val dataBuffer = ByteBuffer.wrap(stlBytes) + dataBuffer.order(ByteOrder.LITTLE_ENDIAN) + val numberOfTriangles = dataBuffer.getInt(80) + val normalOffset = 12 + var surfaceSumMutable = 0.0f + val headerOffset = 84 + val bytesPerTriangle = 50 + for (triangleIndex <- 0 until numberOfTriangles) { + val triangleVerticesOffset = headerOffset + triangleIndex * bytesPerTriangle + normalOffset + val v1x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 0) + val v1y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 1) + val v1z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 2) + val v2x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 3) + val v2y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 4) + val v2z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 5) + val v3x = dataBuffer.getFloat(triangleVerticesOffset + 4 * 6) + val v3y = dataBuffer.getFloat(triangleVerticesOffset + 4 * 7) + val v3z = dataBuffer.getFloat(triangleVerticesOffset + 4 * 8) + + val vec1x = v2x - v1x + val vec1y = v2y - v1y + val vec1z = v2z - v1z + val vec2x = v3x - v1x + val vec2y = v3y - v1y + val vec2z = v3z - v1z + + val crossx = vec1y * vec2z - vec1z * vec2y + val crossy = vec1z * vec2x - vec1x * vec2z + val crossz = vec1x * vec2y - vec1y * vec2x + + val magnitude = Math.sqrt(crossx * crossx + crossy * crossy + crossz * crossz).toFloat + + surfaceSumMutable = surfaceSumMutable + (magnitude / 2.0f) + } + surfaceSumMutable + } } diff --git a/webknossos-datastore/conf/datastore.latest.routes b/webknossos-datastore/conf/datastore.latest.routes index b33f97fa3c..863ab83708 100644 --- a/webknossos-datastore/conf/datastore.latest.routes +++ b/webknossos-datastore/conf/datastore.latest.routes @@ -81,7 +81,6 @@ GET /datasets/:datasetId/layers/:dataLayerName/meshes POST /datasets/:datasetId/layers/:dataLayerName/meshes/chunks @com.scalableminds.webknossos.datastore.controllers.DSMeshController.listMeshChunksForSegment(datasetId: ObjectId, dataLayerName: String, targetMappingName: Option[String], editableMappingTracingId: Option[String]) POST /datasets/:datasetId/layers/:dataLayerName/meshes/chunks/data @com.scalableminds.webknossos.datastore.controllers.DSMeshController.readMeshChunk(datasetId: ObjectId, dataLayerName: String) POST /datasets/:datasetId/layers/:dataLayerName/meshes/fullMesh.stl @com.scalableminds.webknossos.datastore.controllers.DSMeshController.loadFullMeshStl(datasetId: ObjectId, dataLayerName: String) -POST /datasets/:datasetId/layers/:dataLayerName/meshes/fullMeshSurface @com.scalableminds.webknossos.datastore.controllers.DSMeshController.fullMeshSurfaceArea(datasetId: ObjectId, dataLayerName: String) # Connectome files GET /datasets/:datasetId/layers/:dataLayerName/connectomes @com.scalableminds.webknossos.datastore.controllers.DataSourceController.listConnectomeFiles(datasetId: ObjectId, dataLayerName: String) @@ -99,6 +98,7 @@ POST /datasets/:datasetId/layers/:dataLayerName/segmentIndex POST /datasets/:datasetId/layers/:dataLayerName/segmentIndex/:segmentId @com.scalableminds.webknossos.datastore.controllers.DataSourceController.getSegmentIndex(datasetId: ObjectId, dataLayerName: String, segmentId: String) POST /datasets/:datasetId/layers/:dataLayerName/segmentStatistics/volume @com.scalableminds.webknossos.datastore.controllers.DataSourceController.getSegmentVolume(datasetId: ObjectId, dataLayerName: String) POST /datasets/:datasetId/layers/:dataLayerName/segmentStatistics/boundingBox @com.scalableminds.webknossos.datastore.controllers.DataSourceController.getSegmentBoundingBox(datasetId: ObjectId, dataLayerName: String) +POST /datasets/:datasetId/layers/:dataLayerName/segmentStatistics/surfaceArea @com.scalableminds.webknossos.datastore.controllers.DataSourceController.getSegmentSurfaceArea(datasetId: ObjectId, dataLayerName: String) # DataSource management GET /datasets @com.scalableminds.webknossos.datastore.controllers.DataSourceController.testChunk(resumableChunkNumber: Int, resumableIdentifier: String) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala index 5697bba16b..65d873079d 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala @@ -286,7 +286,7 @@ class VolumeTracingController @Inject()( accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readTracing(tracingId)) { for { annotationId <- remoteWebknossosClient.getAnnotationIdForTracing(tracingId) - data: Array[Byte] <- fullMeshService.loadFor(annotationId, tracingId, request.body) ?~> "mesh.file.loadChunk.failed" + data: Array[Byte] <- fullMeshService.loadFor(annotationId, tracingId, request.body) ?~> "mesh.loadFull.failed" } yield Ok(data) } } @@ -347,6 +347,34 @@ class VolumeTracingController @Inject()( } } + def getSegmentSurfaceArea(tracingId: String): Action[SegmentStatisticsParameters] = + Action.async(validateJson[SegmentStatisticsParameters]) { implicit request => + accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readTracing(tracingId)) { + for { + annotationId <- remoteWebknossosClient.getAnnotationIdForTracing(tracingId) + tracing <- annotationService.findVolume(annotationId, tracingId) + baseMappingName <- annotationService.baseMappingName(annotationId, tracingId, tracing) + surfaceAreas <- Fox.serialCombined(request.body.segmentIds) { segmentId => + val fullMeshRequest = FullMeshRequest( + meshFileName = None, + lod = None, + segmentId = segmentId, + mappingName = baseMappingName, + mappingType = baseMappingName.map(_ => "HDF5"), + editableMappingTracingId = None, + mag = Some(request.body.mag), + seedPosition = None, + additionalCoordinates = request.body.additionalCoordinates, + ) + for { + data: Array[Byte] <- fullMeshService.loadFor(annotationId, tracingId, fullMeshRequest) ?~> "mesh.loadFull.failed" + surfaceArea <- fullMeshService.surfaceAreaFromStlBytes(data).toFox + } yield surfaceArea + } + } yield Ok(Json.toJson(surfaceAreas)) + } + } + def getSegmentIndex(tracingId: String, segmentId: Long): Action[GetSegmentIndexParameters] = Action.async(validateJson[GetSegmentIndexParameters]) { implicit request => accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readTracing(tracingId)) { diff --git a/webknossos-tracingstore/conf/tracingstore.latest.routes b/webknossos-tracingstore/conf/tracingstore.latest.routes index 4ed67f7558..5422b3d14c 100644 --- a/webknossos-tracingstore/conf/tracingstore.latest.routes +++ b/webknossos-tracingstore/conf/tracingstore.latest.routes @@ -28,6 +28,7 @@ POST /volume/:tracingId/importVolumeData GET /volume/:tracingId/findData @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.findData(tracingId: String) POST /volume/:tracingId/segmentStatistics/volume @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.getSegmentVolume(tracingId: String) POST /volume/:tracingId/segmentStatistics/boundingBox @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.getSegmentBoundingBox(tracingId: String) +POST /volume/:tracingId/segmentStatistics/surfaceArea @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.getSegmentSurfaceArea(tracingId: String) POST /volume/getMultiple @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.getMultiple POST /volume/mergedFromContents @com.scalableminds.webknossos.tracingstore.controllers.VolumeTracingController.mergedFromContents(newTracingId: String) From 18e24096c6f0f9d3505c27fc26f4979765fddf8c Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 1 Oct 2025 11:14:28 +0200 Subject: [PATCH 05/13] add meshFileName, lod and seedPosition to getSegmentSurfaceArea request body --- .../controllers/DataSourceController.scala | 11 ++++++----- .../datastore/helpers/SegmentStatistics.scala | 11 +++++++++++ .../controllers/VolumeTracingController.scala | 13 +++++++------ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala index dc06f069e6..fe15f737e3 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala @@ -18,6 +18,7 @@ import com.scalableminds.webknossos.datastore.helpers.{ PathSchemes, SegmentIndexData, SegmentStatisticsParameters, + SegmentStatisticsParametersMeshBased, UPath } import com.scalableminds.webknossos.datastore.models.datasource.{DataLayer, DataSource, UsableDataSource} @@ -616,21 +617,21 @@ class DataSourceController @Inject()( } } - def getSegmentSurfaceArea(datasetId: ObjectId, dataLayerName: String): Action[SegmentStatisticsParameters] = - Action.async(validateJson[SegmentStatisticsParameters]) { implicit request => + def getSegmentSurfaceArea(datasetId: ObjectId, dataLayerName: String): Action[SegmentStatisticsParametersMeshBased] = + Action.async(validateJson[SegmentStatisticsParametersMeshBased]) { implicit request => accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readDataset(datasetId)) { for { (dataSource, dataLayer) <- datasetCache.getWithLayer(datasetId, dataLayerName) ~> NOT_FOUND surfaceAreas <- Fox.serialCombined(request.body.segmentIds) { segmentId => val fullMeshRequest = FullMeshRequest( - meshFileName = None, - lod = None, + meshFileName = request.body.meshFileName, + lod = request.body.lod, segmentId = segmentId, mappingName = request.body.mappingName, mappingType = request.body.mappingName.map(_ => "HDF5"), editableMappingTracingId = None, mag = Some(request.body.mag), - seedPosition = None, + seedPosition = request.body.seedPosition, additionalCoordinates = request.body.additionalCoordinates, ) for { diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala index 289d9cc658..36876676f7 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala @@ -18,6 +18,17 @@ case class SegmentStatisticsParameters(mag: Vec3Int, object SegmentStatisticsParameters { implicit val jsonFormat: OFormat[SegmentStatisticsParameters] = Json.format[SegmentStatisticsParameters] } +case class SegmentStatisticsParametersMeshBased(mag: Vec3Int, + segmentIds: List[Long], + mappingName: Option[String], + additionalCoordinates: Option[Seq[AdditionalCoordinate]], + meshFileName: Option[String], + lod: Option[Int], + seedPosition: Option[Vec3Int]) +object SegmentStatisticsParametersMeshBased { + implicit val jsonFormat: OFormat[SegmentStatisticsParametersMeshBased] = + Json.format[SegmentStatisticsParametersMeshBased] +} trait SegmentStatistics extends ProtoGeometryImplicits with FoxImplicits { diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala index 65d873079d..03b8005f55 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala @@ -13,7 +13,8 @@ import com.scalableminds.webknossos.datastore.geometry.Vec3IntProto import com.scalableminds.webknossos.datastore.helpers.{ GetSegmentIndexParameters, ProtoGeometryImplicits, - SegmentStatisticsParameters + SegmentStatisticsParameters, + SegmentStatisticsParametersMeshBased } import com.scalableminds.webknossos.datastore.models.datasource.DataLayer import com.scalableminds.webknossos.datastore.models.{ @@ -347,8 +348,8 @@ class VolumeTracingController @Inject()( } } - def getSegmentSurfaceArea(tracingId: String): Action[SegmentStatisticsParameters] = - Action.async(validateJson[SegmentStatisticsParameters]) { implicit request => + def getSegmentSurfaceArea(tracingId: String): Action[SegmentStatisticsParametersMeshBased] = + Action.async(validateJson[SegmentStatisticsParametersMeshBased]) { implicit request => accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readTracing(tracingId)) { for { annotationId <- remoteWebknossosClient.getAnnotationIdForTracing(tracingId) @@ -356,14 +357,14 @@ class VolumeTracingController @Inject()( baseMappingName <- annotationService.baseMappingName(annotationId, tracingId, tracing) surfaceAreas <- Fox.serialCombined(request.body.segmentIds) { segmentId => val fullMeshRequest = FullMeshRequest( - meshFileName = None, - lod = None, + meshFileName = request.body.meshFileName, + lod = request.body.lod, segmentId = segmentId, mappingName = baseMappingName, mappingType = baseMappingName.map(_ => "HDF5"), editableMappingTracingId = None, mag = Some(request.body.mag), - seedPosition = None, + seedPosition = request.body.seedPosition, additionalCoordinates = request.body.additionalCoordinates, ) for { From 7d56a1a998093b278a759a06fe7e068c43624327 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 1 Oct 2025 16:26:14 +0200 Subject: [PATCH 06/13] integrate first version for surface area into front end --- frontend/javascripts/admin/rest_api.ts | 42 ++++++++++++++++ frontend/javascripts/libs/format_utils.ts | 2 +- .../javascripts/viewer/view/context_menu.tsx | 50 +++++++++++++++---- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 7fc0773631..f02bca601e 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -870,6 +870,48 @@ export function getSegmentVolumes( ); } +type SegmentStatisticsParametersMeshBased = { + mag: Vector3; + segmentIds: number[]; + mappingName?: string | null; + additionalCoordinates?: AdditionalCoordinate[] | null; + meshFileName?: string | null; + lod?: number; + seedPosition?: Vector3; +}; + +export function getSegmentSurfaceArea( + // tracings/volume/:tracingId/... + // data/datasets/:datasetId/layers/:dataLayerName/... + requestUrl: string, + mag: Vector3, + lod: number | undefined, + seedPosition: Vector3 | undefined | null, + meshFileName: string | undefined | null, + segmentIds: Array, + additionalCoordinates: AdditionalCoordinate[] | undefined | null, + mappingName: string | null | undefined, +): Promise { + return doWithToken((token) => { + const data: SegmentStatisticsParametersMeshBased = { + mag, + segmentIds, + mappingName, + additionalCoordinates, + lod, + meshFileName, + seedPosition, + }; + return Request.sendJSONReceiveJSON( + `${requestUrl}/segmentStatistics/surfaceArea?token=${token}`, + { + data, + method: "POST", + }, + ); + }); +} + export function getSegmentBoundingBoxes( requestUrl: string, mag: Vector3, diff --git a/frontend/javascripts/libs/format_utils.ts b/frontend/javascripts/libs/format_utils.ts index 046ee8e84e..eeb7c0908b 100644 --- a/frontend/javascripts/libs/format_utils.ts +++ b/frontend/javascripts/libs/format_utils.ts @@ -293,7 +293,7 @@ export const nmFactorToUnit3D = new Map([ [1e99, "Ym³"], ]); -// Accepts an volume that is interpreted in the given unit and returns a string +// Accepts a volume that is interpreted in the given unit and returns a string // that uses a readable unit to represent the volume. // E.g. formatNumberToVolume(0.003, Unit.m) == "3000.0 cm³" export function formatNumberToVolume( diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index ceae161419..f4aeae07b8 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -1,5 +1,5 @@ import { CopyOutlined, PushpinOutlined, ReloadOutlined, WarningOutlined } from "@ant-design/icons"; -import { getSegmentBoundingBoxes, getSegmentVolumes } from "admin/rest_api"; +import { getSegmentBoundingBoxes, getSegmentSurfaceArea, getSegmentVolumes } from "admin/rest_api"; import { ConfigProvider, Dropdown, @@ -18,7 +18,12 @@ import type { } from "antd/es/menu/interface"; import { AsyncIconButton } from "components/async_clickables"; import FastTooltip from "components/fast_tooltip"; -import { formatLengthAsVx, formatNumberToLength, formatNumberToVolume } from "libs/format_utils"; +import { + formatLengthAsVx, + formatNumberToArea, + formatNumberToLength, + formatNumberToVolume, +} from "libs/format_utils"; import { V3 } from "libs/mjs"; import { useFetch } from "libs/react_helpers"; import { useWkSelector } from "libs/react_hooks"; @@ -1396,7 +1401,7 @@ function getNoNodeContextMenuOptions(props: NoNodeContextMenuProps): ItemType[] ? [ // Segment 0 cannot/shouldn't be made active (as this // would be an eraser effectively). - segmentIdAtPosition > 0 && !disabledVolumeInfo.PICK_CELL.isDisabled + segmentIdAtPosition !== 0 && !disabledVolumeInfo.PICK_CELL.isDisabled ? { key: "select-cell", onClick: () => { @@ -1415,9 +1420,9 @@ function getNoNodeContextMenuOptions(props: NoNodeContextMenuProps): ItemType[] ), } : null, - segmentIdAtPosition > 0 ? onlyShowThisSegmentItem : null, - segmentIdAtPosition > 0 ? toggleSegmentVisibilityItem : null, - segmentIdAtPosition > 0 ? showAllSegmentsItem : null, + segmentIdAtPosition !== 0 ? onlyShowThisSegmentItem : null, + segmentIdAtPosition !== 0 ? toggleSegmentVisibilityItem : null, + segmentIdAtPosition !== 0 ? showAllSegmentsItem : null, focusInSegmentListItem, loadPrecomputedMeshItem, computeMeshAdHocItem, @@ -1682,7 +1687,7 @@ function ContextMenuInner() { }); const isLoadingMessage = "loading"; const isLoadingVolumeAndBB = [isLoadingMessage, isLoadingMessage]; - const [segmentVolumeLabel, boundingBoxInfoLabel] = useFetch( + const [segmentVolumeLabel, boundingBoxInfoLabel, segmentSurfaceAreaLabel] = useFetch( async () => { const { annotation, flycam } = Store.getState(); // The value that is returned if the context menu is closed is shown if it's still loading @@ -1715,6 +1720,17 @@ function ContextMenuInner() { additionalCoordinates, mappingName, ); + const lod = 0; // todop + const [surfaceArea] = await getSegmentSurfaceArea( + requestUrl, + layersFinestMag, + lod, + globalPosition, + currentMeshFile?.name, + [clickedSegmentOrMeshId], + additionalCoordinates, + mappingName, + ); const boundingBoxInMag1 = getBoundingBoxInMag1(boundingBoxInRequestedMag, layersFinestMag); const boundingBoxTopLeftString = `(${boundingBoxInMag1.topLeft[0]}, ${boundingBoxInMag1.topLeft[1]}, ${boundingBoxInMag1.topLeft[2]})`; const boundingBoxSizeString = `(${boundingBoxInMag1.width}, ${boundingBoxInMag1.height}, ${boundingBoxInMag1.depth})`; @@ -1722,6 +1738,7 @@ function ContextMenuInner() { return [ formatNumberToVolume(volumeInUnit3, LongUnitToShortUnitMap[voxelSize.unit]), `${boundingBoxTopLeftString}, ${boundingBoxSizeString}`, + formatNumberToArea(surfaceArea, LongUnitToShortUnitMap[voxelSize.unit]), ]; } catch (_error) { const notFetchedMessage = "could not be fetched"; @@ -1797,7 +1814,7 @@ function ContextMenuInner() { getInfoMenuItem( "positionInfo", <> - Position:{" "} + Position:{" "} {nodePositionAsString} {copyIconWithTooltip(nodePositionAsString, "Copy node position")} , @@ -1810,7 +1827,8 @@ function ContextMenuInner() { getInfoMenuItem( "positionInfo", <> - Position: {positionAsString} + Position:{" "} + {positionAsString} {copyIconWithTooltip(positionAsString, "Copy position")} , ), @@ -1847,9 +1865,19 @@ function ContextMenuInner() { , ), ); - } - if (areSegmentStatisticsAvailable) { + infoRows.push( + getInfoMenuItem( + "volumeInfo", + <> + + Surface Area: {segmentSurfaceAreaLabel} + {copyIconWithTooltip(segmentSurfaceAreaLabel as string, "Copy surface area")} + {refreshButton} + , + ), + ); + infoRows.push( getInfoMenuItem( "boundingBoxPositionInfo", From 6dfe14ce4e7a41cbf69b7f111ed66aeb1f08b1f9 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 1 Oct 2025 16:41:30 +0200 Subject: [PATCH 07/13] also add to segment statistics modal --- frontend/javascripts/admin/rest_api.ts | 2 +- .../model/accessors/volumetracing_accessor.ts | 11 +++++++ .../javascripts/viewer/view/context_menu.tsx | 10 ++----- .../segments_tab/segment_statistics_modal.tsx | 30 ++++++++++++++++--- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index f02bca601e..c2251c9806 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -877,7 +877,7 @@ type SegmentStatisticsParametersMeshBased = { additionalCoordinates?: AdditionalCoordinate[] | null; meshFileName?: string | null; lod?: number; - seedPosition?: Vector3; + seedPosition?: Vector3 | null; }; export function getSegmentSurfaceArea( diff --git a/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts b/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts index 65fb10ebc3..20ae53de5c 100644 --- a/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts +++ b/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts @@ -947,3 +947,14 @@ export function getReadableNameOfVolumeLayer( ? getReadableNameByVolumeTracingId(tracing, layer.tracingId) : null; } + +export function getCurrentMappingName(state: WebknossosState) { + const visibleSegmentationLayer = getVisibleSegmentationLayer(state); + const volumeTracing = getActiveSegmentationTracing(state); + if (volumeTracing?.mappingName != null) return volumeTracing?.mappingName; + const mappingInfo = getMappingInfo( + state.temporaryConfiguration.activeMappingByLayer, + visibleSegmentationLayer?.name, + ); + return mappingInfo.mappingName; +} diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index f4aeae07b8..2df64be504 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -80,6 +80,7 @@ import { maybeGetSomeTracing } from "viewer/model/accessors/tracing_accessor"; import { getActiveCellId, getActiveSegmentationTracing, + getCurrentMappingName, getSegmentsForLayer, hasAgglomerateMapping, hasConnectomeFile, @@ -1677,14 +1678,7 @@ function ContextMenuInner() { dataset, visibleSegmentationLayer?.name, ); - const mappingName: string | null | undefined = useWkSelector((state) => { - if (volumeTracing?.mappingName != null) return volumeTracing?.mappingName; - const mappingInfo = getMappingInfo( - state.temporaryConfiguration.activeMappingByLayer, - visibleSegmentationLayer?.name, - ); - return mappingInfo.mappingName; - }); + const mappingName: string | null | undefined = useWkSelector(getCurrentMappingName); const isLoadingMessage = "loading"; const isLoadingVolumeAndBB = [isLoadingMessage, isLoadingMessage]; const [segmentVolumeLabel, boundingBoxInfoLabel, segmentSurfaceAreaLabel] = useFetch( diff --git a/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx b/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx index bf2c9f0f05..c5aeee8d03 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx @@ -1,4 +1,4 @@ -import { getSegmentBoundingBoxes, getSegmentVolumes } from "admin/rest_api"; +import { getSegmentBoundingBoxes, getSegmentSurfaceArea, getSegmentVolumes } from "admin/rest_api"; import { Alert, Modal, Spin, Table } from "antd"; import { formatNumberToVolume } from "libs/format_utils"; import { useFetch } from "libs/react_helpers"; @@ -11,7 +11,10 @@ import { getAdditionalCoordinatesAsString, hasAdditionalCoordinates, } from "viewer/model/accessors/flycam_accessor"; -import { getVolumeTracingById } from "viewer/model/accessors/volumetracing_accessor"; +import { + getCurrentMappingName, + getVolumeTracingById, +} from "viewer/model/accessors/volumetracing_accessor"; import { saveAsCSV, transformToCSVRow } from "viewer/model/helpers/csv_helpers"; import { getBoundingBoxInMag1 } from "viewer/model/sagas/volume/helpers"; import { voxelToVolumeInUnit } from "viewer/model/scaleinfo"; @@ -116,6 +119,13 @@ export function SegmentStatisticsModal({ additionalCoordinates, ", ", ); + const currentMeshFile = useWkSelector((state) => + visibleSegmentationLayer != null + ? state.localSegmentationData[visibleSegmentationLayer.name].currentMeshFile + : null, + ); + const mappingName: string | null | undefined = useWkSelector(getCurrentMappingName); + const segmentStatisticsObjects = useFetch( async () => { await api.tracing.save(); @@ -130,21 +140,33 @@ export function SegmentStatisticsModal({ ); return mappingInfo.mappingName; }; + const lod = 0; // todop; + const segmentIds = segments.map((segment) => segment.id); const segmentStatisticsObjects = await Promise.all([ getSegmentVolumes( requestUrl, layersFinestMag, - segments.map((segment) => segment.id), + segmentIds, additionalCoordinates, maybeGetMappingName(), ), getSegmentBoundingBoxes( requestUrl, layersFinestMag, - segments.map((segment) => segment.id), + segmentIds, additionalCoordinates, maybeGetMappingName(), ), + getSegmentSurfaceArea( + requestUrl, + layersFinestMag, + lod, + null, + currentMeshFile?.name, + segmentIds, + additionalCoordinates, + mappingName, + ), ]).then( (response) => { const segmentSizes = response[0]; From cc95bbecfe0aaf0bffb67df0f24c4b8ac88f5a15 Mon Sep 17 00:00:00 2001 From: Florian M Date: Thu, 2 Oct 2025 10:24:27 +0200 Subject: [PATCH 08/13] Fix RPC fetching voxel size for annotation --- app/controllers/DatasetController.scala | 28 +++++++++++++++++++ .../WKRemoteDataStoreController.scala | 3 +- .../WKRemoteTracingStoreController.scala | 10 +++++++ conf/webknossos.latest.routes | 3 +- .../TSRemoteDatastoreClient.scala | 13 --------- .../TSRemoteWebknossosClient.scala | 20 +++++++++++-- .../tracings/volume/TSFullMeshService.scala | 2 +- 7 files changed, 60 insertions(+), 19 deletions(-) diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index 1b358acd27..5d3546c967 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -339,6 +339,34 @@ class DatasetController @Inject()(userService: UserService, } yield Ok(Json.toJson(usersJs)) } + // Note that dataSource is also included in the full publicWrites. This + def readDataSource(datasetId: ObjectId, + // Optional sharing token allowing access to datasets your team does not normally have access to.") + sharingToken: Option[String]): Action[AnyContent] = + sil.UserAwareAction.async { implicit request => + log() { + val ctx = URLSharing.fallbackTokenAccessContext(sharingToken) + for { + dataset <- datasetDAO.findOne(datasetId)(ctx) ?~> notFoundMessage(datasetId.toString) ~> NOT_FOUND + organization <- organizationDAO.findOne(dataset._organization)(GlobalAccessContext) ~> NOT_FOUND + _ <- Fox.runOptional(request.identity)(user => + datasetLastUsedTimesDAO.updateForDatasetAndUser(dataset._id, user._id)) + // Access checked above via dataset. In case of shared dataset/annotation, show datastore even if not otherwise accessible + dataStore <- datasetService.dataStoreFor(dataset)(GlobalAccessContext) + js <- datasetService.publicWrites(dataset, request.identity, Some(organization), Some(dataStore)) + _ = request.identity.map { user => + analyticsService.track(OpenDatasetEvent(user, dataset)) + if (dataset.isPublic) { + mailchimpClient.tagUser(user, MailchimpTag.HasViewedPublishedDataset) + } + userDAO.updateLastActivity(user._id) + } + } yield { + Ok(Json.toJson(js)) + } + } + } + def read(datasetId: ObjectId, // Optional sharing token allowing access to datasets your team does not normally have access to.") sharingToken: Option[String]): Action[AnyContent] = diff --git a/app/controllers/WKRemoteDataStoreController.scala b/app/controllers/WKRemoteDataStoreController.scala index 2f289bdcac..18289f9b57 100644 --- a/app/controllers/WKRemoteDataStoreController.scala +++ b/app/controllers/WKRemoteDataStoreController.scala @@ -296,11 +296,10 @@ class WKRemoteDataStoreController @Inject()( Action.async { implicit request => dataStoreService.validateAccess(name, key) { _ => for { - dataset <- datasetDAO.findOne(datasetId)(GlobalAccessContext) + dataset <- datasetDAO.findOne(datasetId)(GlobalAccessContext) ?~> "dataset.notFound" ~> NOT_FOUND dataSource <- datasetService.dataSourceFor(dataset) } yield Ok(Json.toJson(dataSource)) } - } def updateDataSource(name: String, key: String, datasetId: ObjectId): Action[DataSource] = diff --git a/app/controllers/WKRemoteTracingStoreController.scala b/app/controllers/WKRemoteTracingStoreController.scala index 3e2d8efafc..ac867637f0 100644 --- a/app/controllers/WKRemoteTracingStoreController.scala +++ b/app/controllers/WKRemoteTracingStoreController.scala @@ -161,6 +161,16 @@ class WKRemoteTracingStoreController @Inject()(tracingStoreService: TracingStore } } + def getDataSource(name: String, key: String, datasetId: ObjectId): Action[AnyContent] = + Action.async { implicit request => + tracingStoreService.validateAccess(name, key) { _ => + for { + dataset <- datasetDAO.findOne(datasetId)(GlobalAccessContext) ?~> "dataset.notFound" ~> NOT_FOUND + dataSource <- datasetService.dataSourceFor(dataset) + } yield Ok(Json.toJson(dataSource)) + } + } + def createTracing(name: String, key: String, annotationId: ObjectId, diff --git a/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 26b55f6c07..f921e41fb2 100644 --- a/conf/webknossos.latest.routes +++ b/conf/webknossos.latest.routes @@ -103,12 +103,12 @@ GET /datasets/:datasetId/layers/:layer/thumbnail POST /datasets/:datasetId/layers/:layer/segmentAnythingMask controllers.DatasetController.segmentAnythingMask(datasetId: ObjectId, layer: String, intensityMin: Option[Float], intensityMax: Option[Float]) PUT /datasets/:datasetId/clearThumbnailCache controllers.DatasetController.removeFromThumbnailCache(datasetId: ObjectId) GET /datasets/:datasetName/isValidNewName controllers.DatasetController.isValidNewName(datasetName: String) -GET /datasets/:datasetId controllers.DatasetController.read(datasetId: ObjectId, sharingToken: Option[String]) DELETE /datasets/:datasetId/deleteOnDisk controllers.DatasetController.deleteOnDisk(datasetId: ObjectId) POST /datasets/:datasetId/reserveAttachmentUploadToPath controllers.DatasetController.reserveAttachmentUploadToPath(datasetId: ObjectId) POST /datasets/:datasetId/finishAttachmentUploadToPath controllers.DatasetController.finishAttachmentUploadToPath(datasetId: ObjectId) POST /datasets/:datasetId/reserveUploadToPathsForPreliminary controllers.DatasetController.reserveUploadToPathsForPreliminary(datasetId: ObjectId) POST /datasets/:datasetId/finishUploadToPaths controllers.DatasetController.finishUploadToPaths(datasetId: ObjectId) +GET /datasets/:datasetId controllers.DatasetController.read(datasetId: ObjectId, sharingToken: Option[String]) POST /datasets/compose controllers.DatasetController.compose() POST /datasets/reserveUploadToPaths controllers.DatasetController.reserveUploadToPaths() @@ -152,6 +152,7 @@ GET /tracingstores/:name/datasetId GET /tracingstores/:name/annotationId controllers.WKRemoteTracingStoreController.annotationIdForTracing(name: String, key: String, tracingId: String) GET /tracingstores/:name/dataStoreUri/:datasetId controllers.WKRemoteTracingStoreController.dataStoreUriForDataset(name: String, key: String, datasetId: ObjectId) POST /tracingstores/:name/createTracing controllers.WKRemoteTracingStoreController.createTracing(name: String, key: String, annotationId: ObjectId, previousVersion: Long) +GET /tracingstores/:name/datasources/:datasetId controllers.WKRemoteTracingStoreController.getDataSource(name: String, key: String, datasetId: ObjectId) # User access tokens for datastore authentication POST /userToken/generate controllers.UserTokenController.generateTokenForDataStore() diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala index 29c8bb4bb0..4e5c1e087d 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala @@ -37,7 +37,6 @@ class TSRemoteDatastoreClient @Inject()( with MissingBucketHeaders { private lazy val dataStoreUriCache: AlfuCache[ObjectId, String] = AlfuCache() - private lazy val voxelSizeCache: AlfuCache[ObjectId, VoxelSize] = AlfuCache(timeToLive = 10 minutes) private lazy val largestAgglomerateIdCache: AlfuCache[(RemoteFallbackLayer, String, Option[String]), Long] = AlfuCache(timeToLive = 10 minutes) @@ -140,18 +139,6 @@ class TSRemoteDatastoreClient @Inject()( .postJsonWithBytesResponse(fullMeshRequest) } yield result - def voxelSizeForAnnotationWithCache(annotationId: ObjectId)(implicit tc: TokenContext): Fox[VoxelSize] = - voxelSizeCache.getOrLoad(annotationId, aId => voxelSizeForAnnotation(aId)) - - private def voxelSizeForAnnotation(annotationId: ObjectId)(implicit tc: TokenContext): Fox[VoxelSize] = - for { - datasetId <- remoteWebknossosClient.getDatasetIdForAnnotation(annotationId) - dataStoreUri <- dataStoreUriWithCache(datasetId) - result <- rpc(s"$dataStoreUri/data/datasets/$datasetId/readInboxDataSource").withTokenFromContext - .getWithJsonResponse[DataSource] - scale <- result.voxelSizeOpt.toFox ?~> "could not determine voxel size of dataset" - } yield scale - private def getRemoteLayerUri(remoteLayer: RemoteFallbackLayer): Fox[String] = for { datastoreUri <- dataStoreUriWithCache(remoteLayer.datasetId) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala index 270d067e88..acd05cb79f 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala @@ -5,12 +5,13 @@ import com.scalableminds.util.accesscontext.TokenContext import com.scalableminds.util.cache.AlfuCache import com.scalableminds.util.objectid.ObjectId import com.scalableminds.util.time.Instant -import com.scalableminds.util.tools.Fox +import com.scalableminds.util.tools.{Fox, FoxImplicits} import com.scalableminds.webknossos.datastore.Annotation.AnnotationProto import com.scalableminds.webknossos.datastore.SkeletonTracing.SkeletonTracing import com.scalableminds.webknossos.datastore.VolumeTracing.VolumeTracing +import com.scalableminds.webknossos.datastore.models.VoxelSize import com.scalableminds.webknossos.datastore.models.annotation.AnnotationLayerType -import com.scalableminds.webknossos.datastore.models.datasource.UsableDataSource +import com.scalableminds.webknossos.datastore.models.datasource.{DataSource, UsableDataSource} import com.scalableminds.webknossos.datastore.rpc.RPC import com.scalableminds.webknossos.datastore.services.{ AccessTokenService, @@ -43,6 +44,7 @@ class TSRemoteWebknossosClient @Inject()( config: TracingStoreConfig, val lifecycle: ApplicationLifecycle ) extends RemoteWebknossosClient + with FoxImplicits with LazyLogging { private val tracingStoreKey: String = config.Tracingstore.key @@ -53,6 +55,7 @@ class TSRemoteWebknossosClient @Inject()( private lazy val datasetIdByAnnotationIdCache: AlfuCache[ObjectId, ObjectId] = AlfuCache() private lazy val annotationIdByTracingIdCache: AlfuCache[String, ObjectId] = AlfuCache(maxCapacity = 10000, timeToLive = 5 minutes) + private lazy val voxelSizeCache: AlfuCache[ObjectId, VoxelSize] = AlfuCache(timeToLive = 10 minutes) def reportAnnotationUpdates(tracingUpdatesReport: AnnotationUpdatesReport): Fox[WSResponse] = rpc(s"$webknossosUri/api/tracingstores/$tracingStoreName/handleTracingUpdateReport") @@ -125,6 +128,19 @@ class TSRemoteWebknossosClient @Inject()( } } + def voxelSizeForAnnotationWithCache(annotationId: ObjectId)(implicit tc: TokenContext, + ec: ExecutionContext): Fox[VoxelSize] = + voxelSizeCache.getOrLoad(annotationId, aId => voxelSizeForAnnotation(aId)) + + private def voxelSizeForAnnotation(annotationId: ObjectId)(implicit tc: TokenContext, + ec: ExecutionContext): Fox[VoxelSize] = + for { + datasetId <- getDatasetIdForAnnotation(annotationId) + result <- rpc(s"$webknossosUri/api/tracingstores/$tracingStoreName/datasources/$datasetId") + .getWithJsonResponse[DataSource] + scale <- result.voxelSizeOpt.toFox ?~> "Could not determine voxel size of dataset" + } yield scale + override def requestUserAccess(accessRequest: UserAccessRequest)(implicit tc: TokenContext): Fox[UserAccessAnswer] = rpc(s"$webknossosUri/api/tracingstores/$tracingStoreName/validateUserAccess") .addQueryString("key" -> tracingStoreKey) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/TSFullMeshService.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/TSFullMeshService.scala index c70d80c0ac..816f1590f1 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/TSFullMeshService.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/TSFullMeshService.scala @@ -72,7 +72,7 @@ class TSFullMeshService @Inject()(volumeTracingService: VolumeTracingService, mag <- fullMeshRequest.mag.toFox ?~> "mag.neededForAdHoc" _ <- Fox.fromBool(tracing.mags.contains(vec3IntToProto(mag))) ?~> "mag.notPresentInTracing" before = Instant.now - voxelSize <- remoteDatastoreClient.voxelSizeForAnnotationWithCache(annotationId) ?~> "voxelSize.failedToFetch" + voxelSize <- remoteWebknossosClient.voxelSizeForAnnotationWithCache(annotationId) ?~> "voxelSize.failedToFetch" verticesForChunks <- if (tracing.hasSegmentIndex.getOrElse(false)) getAllAdHocChunksWithSegmentIndex(annotationId, tracingId, tracing, mag, voxelSize, fullMeshRequest) else From bad085583b7c85946b7feb6fec858e1fbb96e398 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 7 Oct 2025 17:11:42 +0200 Subject: [PATCH 09/13] only show segment stats when explicitly triggered --- .../javascripts/viewer/view/context_menu.tsx | 159 +++++++++--------- 1 file changed, 79 insertions(+), 80 deletions(-) diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index db66ee7eb0..85e9095a64 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -1,4 +1,9 @@ -import { CopyOutlined, PushpinOutlined, ReloadOutlined, WarningOutlined } from "@ant-design/icons"; +import { + BarChartOutlined, + CopyOutlined, + PushpinOutlined, + WarningOutlined, +} from "@ant-design/icons"; import { getSegmentBoundingBoxes, getSegmentSurfaceArea, getSegmentVolumes } from "admin/rest_api"; import { ConfigProvider, @@ -16,7 +21,6 @@ import type { MenuItemType, SubMenuType, } from "antd/es/menu/interface"; -import { AsyncIconButton } from "components/async_clickables"; import FastTooltip from "components/fast_tooltip"; import { formatLengthAsVx, @@ -1656,9 +1660,13 @@ function ContextMenuInner() { viewport: maybeViewport, } = contextInfo; - const [lastTimeSegmentInfoShouldBeFetched, setLastTimeSegmentInfoShouldBeFetched] = useState( - new Date(), - ); + const [segmentStatsTriggerDate, setSegmentStatsTriggerDate] = useState(null); + + const handleRefreshSegmentStatistics = async () => { + await api.tracing.save(); + setSegmentStatsTriggerDate(new Date()); + }; + const inputRef = useContext(ContextMenuContext); const segmentIdAtPosition = globalPosition != null ? getSegmentIdForPosition(globalPosition) : 0; @@ -1680,13 +1688,17 @@ function ContextMenuInner() { ); const mappingName: string | null | undefined = useWkSelector(getCurrentMappingName); const isLoadingMessage = "loading"; - const isLoadingVolumeAndBB = [isLoadingMessage, isLoadingMessage]; + const isLoadingLabelTuple = [isLoadingMessage, isLoadingMessage, isLoadingMessage] as const; const [segmentVolumeLabel, boundingBoxInfoLabel, segmentSurfaceAreaLabel] = useFetch( - async () => { + async (): Promise => { + if (segmentStatsTriggerDate == null) { + // Should never be rendered because segmentStatsTriggerDate is null. + return isLoadingLabelTuple; + } const { annotation, flycam } = Store.getState(); // The value that is returned if the context menu is closed is shown if it's still loading - if (contextMenuPosition == null || !wasSegmentOrMeshClicked) return isLoadingVolumeAndBB; - if (visibleSegmentationLayer == null || !isSegmentIndexAvailable) return []; + if (contextMenuPosition == null || !wasSegmentOrMeshClicked) return isLoadingLabelTuple; + if (visibleSegmentationLayer == null || !isSegmentIndexAvailable) return isLoadingLabelTuple; const tracingId = volumeTracing?.tracingId; const additionalCoordinates = flycam.additionalCoordinates; const requestUrl = getVolumeRequestUrl( @@ -1735,19 +1747,14 @@ function ContextMenuInner() { formatNumberToArea(surfaceArea, LongUnitToShortUnitMap[voxelSize.unit]), ]; } catch (_error) { - const notFetchedMessage = "could not be fetched"; - return [notFetchedMessage, notFetchedMessage]; + const notFetchedMessage = "Could not be fetched."; + return [notFetchedMessage, notFetchedMessage, notFetchedMessage]; } }, - isLoadingVolumeAndBB, + isLoadingLabelTuple, // Update segment infos when opening the context menu, in case the annotation was saved since the context menu was last opened. // Of course the info should also be updated when the menu is opened for another segment, or after the refresh button was pressed. - [ - contextMenuPosition, - isSegmentIndexAvailable, - clickedSegmentOrMeshId, - lastTimeSegmentInfoShouldBeFetched, - ], + [contextMenuPosition, isSegmentIndexAvailable, clickedSegmentOrMeshId, segmentStatsTriggerDate], ); let nodeContextMenuTree: Tree | null = null; @@ -1794,6 +1801,19 @@ function ContextMenuInner() { : ""; const infoRows: ItemType[] = []; + const areSegmentStatisticsAvailable = wasSegmentOrMeshClicked && isSegmentIndexAvailable; + if (areSegmentStatisticsAvailable) { + infoRows.push({ + key: "load-stats", + icon: , + label: `${segmentStatsTriggerDate != null ? "Reload" : "Load"} segment statistics`, + onClick: (event) => { + event.domEvent.preventDefault(); + handleRefreshSegmentStatistics(); + }, + }); + } + if (maybeClickedNodeId != null && nodeContextMenuTree != null) { infoRows.push( getInfoMenuItem( @@ -1829,68 +1849,6 @@ function ContextMenuInner() { ); } - const handleRefreshSegmentVolume = async () => { - await api.tracing.save(); - setLastTimeSegmentInfoShouldBeFetched(new Date()); - }; - - const refreshButton = ( - - } - style={{ marginLeft: 4 }} - /> - - ); - - const areSegmentStatisticsAvailable = wasSegmentOrMeshClicked && isSegmentIndexAvailable; - - if (areSegmentStatisticsAvailable) { - infoRows.push( - getInfoMenuItem( - "volumeInfo", - <> - - Volume: {segmentVolumeLabel} - {copyIconWithTooltip(segmentVolumeLabel as string, "Copy volume")} - {refreshButton} - , - ), - ); - - infoRows.push( - getInfoMenuItem( - "volumeInfo", - <> - - Surface Area: {segmentSurfaceAreaLabel} - {copyIconWithTooltip(segmentSurfaceAreaLabel as string, "Copy surface area")} - {refreshButton} - , - ), - ); - - infoRows.push( - getInfoMenuItem( - "boundingBoxPositionInfo", - <> - - <>Bounding Box: -
- {boundingBoxInfoLabel} - {copyIconWithTooltip( - boundingBoxInfoLabel as string, - "Copy BBox top left point and extent", - )} - {refreshButton} -
- , - ), - ); - } - if (distanceToSelection != null) { infoRows.push( getInfoMenuItem( @@ -1938,6 +1896,47 @@ function ContextMenuInner() { } } + if (areSegmentStatisticsAvailable && segmentStatsTriggerDate != null) { + infoRows.push( + getInfoMenuItem( + "volumeInfo", + <> + + Surface Area: {segmentSurfaceAreaLabel} + {copyIconWithTooltip(segmentSurfaceAreaLabel as string, "Copy surface area")} + , + ), + ); + + infoRows.push( + getInfoMenuItem( + "volumeInfo", + <> + + Volume: {segmentVolumeLabel} + {copyIconWithTooltip(segmentVolumeLabel as string, "Copy volume")} + , + ), + ); + + infoRows.push( + getInfoMenuItem( + "boundingBoxPositionInfo", + <> + + <>Bounding Box: +
+ {boundingBoxInfoLabel} + {copyIconWithTooltip( + boundingBoxInfoLabel as string, + "Copy BBox top left point and extent", + )} +
+ , + ), + ); + } + if (infoRows.length > 0) { infoRows.unshift({ key: "divider", From 2ea2456787962d0a7b3d8e6edd2c885ab1fd1032 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 7 Oct 2025 17:22:32 +0200 Subject: [PATCH 10/13] integrate surface area into stats modal and csv export --- .../segments_tab/segment_statistics_modal.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx b/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx index 14ea76718e..d643cb1fc5 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx @@ -1,6 +1,6 @@ import { getSegmentBoundingBoxes, getSegmentSurfaceArea, getSegmentVolumes } from "admin/rest_api"; import { Alert, Modal, Spin, Table } from "antd"; -import { formatNumberToVolume } from "libs/format_utils"; +import { formatNumberToArea, formatNumberToVolume } from "libs/format_utils"; import { useFetch } from "libs/react_helpers"; import { useWkSelector } from "libs/react_hooks"; import { pluralize } from "libs/utils"; @@ -25,14 +25,17 @@ import { type SegmentHierarchyNode, getVolumeRequestUrl, } from "./segments_view_helper"; +import _ from "lodash"; const MODAL_ERROR_MESSAGE = "Segment statistics could not be fetched. Check the console for more details."; const CONSOLE_ERROR_MESSAGE = "Segment statistics could not be fetched due to the following reason:"; -const getSegmentStatisticsCSVHeader = (dataSourceUnit: string) => - `segmendId,segmentName,groupId,groupName,volumeInVoxel,volumeIn${dataSourceUnit}3,boundingBoxTopLeftPositionX,boundingBoxTopLeftPositionY,boundingBoxTopLeftPositionZ,boundingBoxSizeX,boundingBoxSizeY,boundingBoxSizeZ`; +const getSegmentStatisticsCSVHeader = (dataSourceUnit: string) => { + const capitalizedUnit = _.capitalize(dataSourceUnit); + return `segmendId,segmentName,groupId,groupName,volumeInVoxel,volumeIn${capitalizedUnit}3,surfaceAreaIn${capitalizedUnit}2,boundingBoxTopLeftPositionX,boundingBoxTopLeftPositionY,boundingBoxTopLeftPositionZ,boundingBoxSizeX,boundingBoxSizeY,boundingBoxSizeZ` +}; const ADDITIONAL_COORDS_COLUMN = "additionalCoordinates"; @@ -55,6 +58,8 @@ type SegmentInfo = { volumeInUnit3: number; formattedSize: string; volumeInVoxel: number; + surfaceAreaInUnit2: number; + formattedSurfaceArea: string; boundingBoxTopLeft: Vector3; boundingBoxTopLeftAsString: string; boundingBoxPosition: Vector3; @@ -78,6 +83,7 @@ const exportStatisticsToCSV = ( row.groupName, row.volumeInVoxel, row.volumeInUnit3, + row.surfaceAreaInUnit2, ...row.boundingBoxTopLeft, ...row.boundingBoxPosition, ]); @@ -171,6 +177,7 @@ export function SegmentStatisticsModal({ (response) => { const segmentSizes = response[0]; const boundingBoxes = response[1]; + const surfaceAreasInUnit2 = response[2]; const statisticsObjects = []; const additionalCoordStringForCsv = getAdditionalCoordinatesAsString(additionalCoordinates); @@ -178,6 +185,7 @@ export function SegmentStatisticsModal({ // Segments in request and their statistics in the response are in the same order const currentSegment = segments[i]; const currentBoundingBox = boundingBoxes[i]; + const surfaceAreaInUnit2 = surfaceAreasInUnit2[i]; const boundingBoxInMag1 = getBoundingBoxInMag1(currentBoundingBox, layersFinestMag); const currentSegmentSizeInVx = segmentSizes[i]; const volumeInUnit3 = voxelToVolumeInUnit( @@ -195,11 +203,16 @@ export function SegmentStatisticsModal({ groupId: currentGroupId, groupName: getGroupNameForId(currentGroupId), volumeInVoxel: currentSegmentSizeInVx, - volumeInUnit3: volumeInUnit3, + volumeInUnit3, formattedSize: formatNumberToVolume( volumeInUnit3, LongUnitToShortUnitMap[voxelSize.unit], ), + surfaceAreaInUnit2, + formattedSurfaceArea: formatNumberToArea( + surfaceAreaInUnit2, + LongUnitToShortUnitMap[voxelSize.unit], + ), boundingBoxTopLeft: boundingBoxInMag1.topLeft, boundingBoxTopLeftAsString: `(${boundingBoxInMag1.topLeft.join(", ")})`, boundingBoxPosition: [ @@ -227,6 +240,7 @@ export function SegmentStatisticsModal({ { title: "Segment ID", dataIndex: "segmentId", key: "segmentId" }, { title: "Segment Name", dataIndex: "segmentName", key: "segmentName" }, { title: "Volume", dataIndex: "formattedSize", key: "formattedSize" }, + { title: "Surface Area", dataIndex: "formattedSurfaceArea", key: "formattedSurfaceArea" }, { title: "Bounding Box\nTop Left Position", dataIndex: "boundingBoxTopLeftAsString", @@ -269,7 +283,7 @@ export function SegmentStatisticsModal({ open title="Segment Statistics" onCancel={onCancel} - width={700} + width={800} onOk={() => !isErrorCase && exportStatisticsToCSV( From ae623385a9f31111539092b60593b30f727f8fac Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 7 Oct 2025 17:24:52 +0200 Subject: [PATCH 11/13] remove seed pos --- frontend/javascripts/admin/rest_api.ts | 4 ---- frontend/javascripts/viewer/view/context_menu.tsx | 1 - .../segments_tab/segment_statistics_modal.tsx | 5 ++--- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index c2251c9806..fb3199fa29 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -881,12 +881,9 @@ type SegmentStatisticsParametersMeshBased = { }; export function getSegmentSurfaceArea( - // tracings/volume/:tracingId/... - // data/datasets/:datasetId/layers/:dataLayerName/... requestUrl: string, mag: Vector3, lod: number | undefined, - seedPosition: Vector3 | undefined | null, meshFileName: string | undefined | null, segmentIds: Array, additionalCoordinates: AdditionalCoordinate[] | undefined | null, @@ -900,7 +897,6 @@ export function getSegmentSurfaceArea( additionalCoordinates, lod, meshFileName, - seedPosition, }; return Request.sendJSONReceiveJSON( `${requestUrl}/segmentStatistics/surfaceArea?token=${token}`, diff --git a/frontend/javascripts/viewer/view/context_menu.tsx b/frontend/javascripts/viewer/view/context_menu.tsx index 85e9095a64..76a7088228 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -1731,7 +1731,6 @@ function ContextMenuInner() { requestUrl, layersFinestMag, lod, - globalPosition, currentMeshFile?.name, [clickedSegmentOrMeshId], additionalCoordinates, diff --git a/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx b/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx index d643cb1fc5..cee7d6ec38 100644 --- a/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx +++ b/frontend/javascripts/viewer/view/right-border-tabs/segments_tab/segment_statistics_modal.tsx @@ -4,6 +4,7 @@ import { formatNumberToArea, formatNumberToVolume } from "libs/format_utils"; import { useFetch } from "libs/react_helpers"; import { useWkSelector } from "libs/react_hooks"; import { pluralize } from "libs/utils"; +import _ from "lodash"; import type { APISegmentationLayer, VoxelSize } from "types/api_types"; import { LongUnitToShortUnitMap, type Vector3 } from "viewer/constants"; import { getMagInfo, getMappingInfo } from "viewer/model/accessors/dataset_accessor"; @@ -25,7 +26,6 @@ import { type SegmentHierarchyNode, getVolumeRequestUrl, } from "./segments_view_helper"; -import _ from "lodash"; const MODAL_ERROR_MESSAGE = "Segment statistics could not be fetched. Check the console for more details."; @@ -34,7 +34,7 @@ const CONSOLE_ERROR_MESSAGE = const getSegmentStatisticsCSVHeader = (dataSourceUnit: string) => { const capitalizedUnit = _.capitalize(dataSourceUnit); - return `segmendId,segmentName,groupId,groupName,volumeInVoxel,volumeIn${capitalizedUnit}3,surfaceAreaIn${capitalizedUnit}2,boundingBoxTopLeftPositionX,boundingBoxTopLeftPositionY,boundingBoxTopLeftPositionZ,boundingBoxSizeX,boundingBoxSizeY,boundingBoxSizeZ` + return `segmendId,segmentName,groupId,groupName,volumeInVoxel,volumeIn${capitalizedUnit}3,surfaceAreaIn${capitalizedUnit}2,boundingBoxTopLeftPositionX,boundingBoxTopLeftPositionY,boundingBoxTopLeftPositionZ,boundingBoxSizeX,boundingBoxSizeY,boundingBoxSizeZ`; }; const ADDITIONAL_COORDS_COLUMN = "additionalCoordinates"; @@ -167,7 +167,6 @@ export function SegmentStatisticsModal({ requestUrl, layersFinestMag, lod, - null, currentMeshFile?.name, segmentIds, additionalCoordinates, From 9940941cbe09325e7bc1f16ae454bc0bc653b75c Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 8 Oct 2025 10:13:10 +0200 Subject: [PATCH 12/13] fix voxelSize rpc; remove lod param; check meshfile mapping matches --- .../datastore/controllers/DataSourceController.scala | 8 ++++++-- .../webknossos/datastore/helpers/SegmentStatistics.scala | 1 - .../tracingstore/TSRemoteWebknossosClient.scala | 1 + .../controllers/VolumeTracingController.scala | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala index 1409c74d40..d34a000207 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala @@ -618,10 +618,14 @@ class DataSourceController @Inject()( accessTokenService.validateAccessFromTokenContext(UserAccessRequest.readDataset(datasetId)) { for { (dataSource, dataLayer) <- datasetCache.getWithLayer(datasetId, dataLayerName) ~> NOT_FOUND + meshFileKeyOpt <- Fox.runOptional(request.body.meshFileName)( + meshFileService.lookUpMeshFileKey(dataSource.id, dataLayer, _)) + mappingNameForMeshFile <- Fox.runOptional(meshFileKeyOpt)(meshFileService.mappingNameForMeshFile) surfaceAreas <- Fox.serialCombined(request.body.segmentIds) { segmentId => val fullMeshRequest = FullMeshRequest( - meshFileName = request.body.meshFileName, - lod = request.body.lod, + meshFileName = + if (mappingNameForMeshFile.contains(request.body.meshFileName)) request.body.meshFileName else None, + lod = None, segmentId = segmentId, mappingName = request.body.mappingName, mappingType = request.body.mappingName.map(_ => "HDF5"), diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala index 36876676f7..94d9ac2f48 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala @@ -23,7 +23,6 @@ case class SegmentStatisticsParametersMeshBased(mag: Vec3Int, mappingName: Option[String], additionalCoordinates: Option[Seq[AdditionalCoordinate]], meshFileName: Option[String], - lod: Option[Int], seedPosition: Option[Vec3Int]) object SegmentStatisticsParametersMeshBased { implicit val jsonFormat: OFormat[SegmentStatisticsParametersMeshBased] = diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala index acd05cb79f..0d21c1619d 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala @@ -137,6 +137,7 @@ class TSRemoteWebknossosClient @Inject()( for { datasetId <- getDatasetIdForAnnotation(annotationId) result <- rpc(s"$webknossosUri/api/tracingstores/$tracingStoreName/datasources/$datasetId") + .addQueryString("key" -> tracingStoreKey) .getWithJsonResponse[DataSource] scale <- result.voxelSizeOpt.toFox ?~> "Could not determine voxel size of dataset" } yield scale diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala index 03b8005f55..60c23f8759 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala @@ -357,8 +357,8 @@ class VolumeTracingController @Inject()( baseMappingName <- annotationService.baseMappingName(annotationId, tracingId, tracing) surfaceAreas <- Fox.serialCombined(request.body.segmentIds) { segmentId => val fullMeshRequest = FullMeshRequest( - meshFileName = request.body.meshFileName, - lod = request.body.lod, + meshFileName = None, // Cannot use static meshfiles for dynamic volume layers + lod = None, segmentId = segmentId, mappingName = baseMappingName, mappingType = baseMappingName.map(_ => "HDF5"), From 31726987ef7a7b62c68a4bec6a2059bf03d8f5e9 Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 8 Oct 2025 10:24:27 +0200 Subject: [PATCH 13/13] add token to request, remove unused imports --- .../webknossos/tracingstore/TSRemoteDatastoreClient.scala | 3 +-- .../webknossos/tracingstore/TSRemoteWebknossosClient.scala | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala index 4e5c1e087d..acbf607506 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala @@ -15,8 +15,7 @@ import com.scalableminds.webknossos.datastore.helpers.{ ProtoGeometryImplicits, SegmentIndexData } -import com.scalableminds.webknossos.datastore.models.datasource.DataSource -import com.scalableminds.webknossos.datastore.models.{VoxelSize, WebknossosDataRequest} +import com.scalableminds.webknossos.datastore.models.WebknossosDataRequest import com.scalableminds.webknossos.datastore.rpc.RPC import com.scalableminds.webknossos.datastore.services.mesh.FullMeshRequest import com.scalableminds.webknossos.tracingstore.tracings.RemoteFallbackLayer diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala index 0d21c1619d..c88542f649 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteWebknossosClient.scala @@ -138,6 +138,8 @@ class TSRemoteWebknossosClient @Inject()( datasetId <- getDatasetIdForAnnotation(annotationId) result <- rpc(s"$webknossosUri/api/tracingstores/$tracingStoreName/datasources/$datasetId") .addQueryString("key" -> tracingStoreKey) + .withTokenFromContext + .silent .getWithJsonResponse[DataSource] scale <- result.voxelSizeOpt.toFox ?~> "Could not determine voxel size of dataset" } yield scale