diff --git a/app/controllers/DatasetController.scala b/app/controllers/DatasetController.scala index eff38f5a49..0300236521 100755 --- a/app/controllers/DatasetController.scala +++ b/app/controllers/DatasetController.scala @@ -343,6 +343,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 5737958beb..c1139fd234 100644 --- a/app/controllers/WKRemoteDataStoreController.scala +++ b/app/controllers/WKRemoteDataStoreController.scala @@ -261,11 +261,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/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/conf/webknossos.latest.routes b/conf/webknossos.latest.routes index 835fb8a87b..1df504fefe 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/frontend/javascripts/admin/rest_api.ts b/frontend/javascripts/admin/rest_api.ts index 7fc0773631..63ca72ff3a 100644 --- a/frontend/javascripts/admin/rest_api.ts +++ b/frontend/javascripts/admin/rest_api.ts @@ -870,6 +870,40 @@ export function getSegmentVolumes( ); } +type SegmentStatisticsParametersMeshBased = { + mag: Vector3; + segmentIds: number[]; + mappingName?: string | null; + additionalCoordinates?: AdditionalCoordinate[] | null; + meshFileName?: string | null; +}; + +export function getSegmentSurfaceArea( + requestUrl: string, + mag: Vector3, + 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, + meshFileName, + }; + 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/model/accessors/volumetracing_accessor.ts b/frontend/javascripts/viewer/model/accessors/volumetracing_accessor.ts index 59f3af6a12..d168a291be 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 149b4351cf..d21a59eed1 100644 --- a/frontend/javascripts/viewer/view/context_menu.tsx +++ b/frontend/javascripts/viewer/view/context_menu.tsx @@ -1,5 +1,10 @@ -import { CopyOutlined, PushpinOutlined, ReloadOutlined, WarningOutlined } from "@ant-design/icons"; -import { getSegmentBoundingBoxes, getSegmentVolumes } from "admin/rest_api"; +import { + BarChartOutlined, + CopyOutlined, + PushpinOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { getSegmentBoundingBoxes, getSegmentSurfaceArea, getSegmentVolumes } from "admin/rest_api"; import { ConfigProvider, Dropdown, @@ -16,9 +21,13 @@ import type { MenuItemType, SubMenuType, } 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"; @@ -26,6 +35,7 @@ import Shortcut from "libs/shortcut_component"; import Toast from "libs/toast"; import { hexToRgb, rgbToHex, roundTo, truncateStringToLength } from "libs/utils"; import messages from "messages"; +import type { MenuInfo } from "rc-menu/lib/interface"; import React, { createContext, type MouseEvent, useContext, useEffect, useState } from "react"; import type { Dispatch } from "redux"; import type { @@ -75,6 +85,7 @@ import { maybeGetSomeTracing } from "viewer/model/accessors/tracing_accessor"; import { getActiveCellId, getActiveSegmentationTracing, + getCurrentMappingName, getSegmentsForLayer, hasAgglomerateMapping, hasConnectomeFile, @@ -189,7 +200,12 @@ type NoNodeContextMenuProps = Props & { infoRows: ItemType[]; }; -const hideContextMenu = () => Store.dispatch(hideContextMenuAction()); +const hideContextMenu = (info?: MenuInfo | undefined) => { + if (info?.key === "load-stats") { + return; + } + Store.dispatch(hideContextMenuAction()); +}; export const getNoActionsAvailableMenu = (hideContextMenu: () => void): MenuProps => ({ onClick: hideContextMenu, @@ -1396,7 +1412,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 +1431,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, @@ -1650,9 +1666,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; @@ -1672,22 +1692,19 @@ 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] = useFetch( - async () => { + const isLoadingLabelTuple = [isLoadingMessage, isLoadingMessage, isLoadingMessage] as const; + const [segmentVolumeLabel, boundingBoxInfoLabel, segmentSurfaceAreaLabel] = useFetch( + 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( @@ -1715,6 +1732,14 @@ function ContextMenuInner() { additionalCoordinates, mappingName, ); + const [surfaceArea] = await getSegmentSurfaceArea( + requestUrl, + layersFinestMag, + 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,21 +1747,17 @@ function ContextMenuInner() { return [ formatNumberToVolume(volumeInUnit3, LongUnitToShortUnitMap[voxelSize.unit]), `${boundingBoxTopLeftString}, ${boundingBoxSizeString}`, + 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; @@ -1783,6 +1804,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( @@ -1797,7 +1831,7 @@ function ContextMenuInner() { getInfoMenuItem( "positionInfo", <> - Position:{" "} + Position:{" "} {nodePositionAsString} {copyIconWithTooltip(nodePositionAsString, "Copy node position")} , @@ -1810,65 +1844,14 @@ function ContextMenuInner() { getInfoMenuItem( "positionInfo", <> - Position: {positionAsString} + Position:{" "} + {positionAsString} {copyIconWithTooltip(positionAsString, "Copy position")} , ), ); } - 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} - , - ), - ); - } - - if (areSegmentStatisticsAvailable) { - 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( @@ -1916,6 +1899,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", 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 9084476fc2..3b1ec021ec 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,9 +1,10 @@ -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 { 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"; @@ -11,7 +12,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"; @@ -28,8 +32,10 @@ const MODAL_ERROR_MESSAGE = 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"; @@ -52,6 +58,8 @@ type SegmentInfo = { volumeInUnit3: number; formattedSize: string; volumeInVoxel: number; + surfaceAreaInUnit2: number; + formattedSurfaceArea: string; boundingBoxTopLeft: Vector3; boundingBoxTopLeftAsString: string; boundingBoxPosition: Vector3; @@ -75,6 +83,7 @@ const exportStatisticsToCSV = ( row.groupName, row.volumeInVoxel, row.volumeInUnit3, + row.surfaceAreaInUnit2, ...row.boundingBoxTopLeft, ...row.boundingBoxPosition, ]); @@ -116,6 +125,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,25 +146,35 @@ export function SegmentStatisticsModal({ ); return mappingInfo.mappingName; }; + 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, + currentMeshFile?.name, + segmentIds, + additionalCoordinates, + mappingName, + ), ]).then( (response) => { const segmentSizes = response[0]; const boundingBoxes = response[1]; + const surfaceAreasInUnit2 = response[2]; const statisticsObjects = []; const additionalCoordStringForCsv = getAdditionalCoordinatesAsString(additionalCoordinates); @@ -156,6 +182,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( @@ -173,11 +200,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: [ @@ -205,6 +237,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", @@ -247,7 +280,7 @@ export function SegmentStatisticsModal({ open title="Segment Statistics" onCancel={onCancel} - width={700} + width={800} onOk={() => !isErrorCase && exportStatisticsToCSV( 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..dabf13b4f7 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DSMeshController.scala @@ -95,4 +95,5 @@ class DSMeshController @Inject()( } yield Ok(data) } } + } 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 feb02b967f..d34a000207 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/controllers/DataSourceController.scala @@ -18,12 +18,18 @@ import com.scalableminds.webknossos.datastore.helpers.{ PathSchemes, SegmentIndexData, SegmentStatisticsParameters, + SegmentStatisticsParametersMeshBased, UPath } 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 @@ -67,6 +73,7 @@ class DataSourceController @Inject()( slackNotificationService: DSSlackNotificationService, datasetErrorLoggingService: DSDatasetErrorLoggingService, exploreRemoteLayerService: ExploreRemoteLayerService, + fullMeshService: DSFullMeshService, uploadService: UploadService, meshFileService: MeshFileService, remoteSourceDescriptorService: RemoteSourceDescriptorService, @@ -606,6 +613,36 @@ class DataSourceController @Inject()( } } + 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 + 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 = + 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"), + editableMappingTracingId = None, + mag = Some(request.body.mag), + seedPosition = request.body.seedPosition, + 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/helpers/SegmentStatistics.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/helpers/SegmentStatistics.scala index 289d9cc658..94d9ac2f48 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,16 @@ 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], + seedPosition: Option[Vec3Int]) +object SegmentStatisticsParametersMeshBased { + implicit val jsonFormat: OFormat[SegmentStatisticsParametersMeshBased] = + Json.format[SegmentStatisticsParametersMeshBased] +} trait SegmentStatistics extends ProtoGeometryImplicits with FoxImplicits { 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 7da6b2d464..605e9f5e3e 100644 --- a/webknossos-datastore/conf/datastore.latest.routes +++ b/webknossos-datastore/conf/datastore.latest.routes @@ -98,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/TSRemoteDatastoreClient.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/TSRemoteDatastoreClient.scala index 29c8bb4bb0..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 @@ -37,7 +36,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 +138,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..c88542f649 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,22 @@ 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") + .addQueryString("key" -> tracingStoreKey) + .withTokenFromContext + .silent + .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/controllers/VolumeTracingController.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/controllers/VolumeTracingController.scala index 5697bba16b..60c23f8759 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.{ @@ -286,7 +287,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 +348,34 @@ class VolumeTracingController @Inject()( } } + def getSegmentSurfaceArea(tracingId: String): Action[SegmentStatisticsParametersMeshBased] = + Action.async(validateJson[SegmentStatisticsParametersMeshBased]) { 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, // Cannot use static meshfiles for dynamic volume layers + lod = None, + segmentId = segmentId, + mappingName = baseMappingName, + mappingType = baseMappingName.map(_ => "HDF5"), + editableMappingTracingId = None, + mag = Some(request.body.mag), + seedPosition = request.body.seedPosition, + 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/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 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)