From 5e0e962a6a985289043dbfb90f8b69bca4027356 Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 7 Aug 2023 17:30:36 +0200 Subject: [PATCH 1/5] Automatically assign color and channel name for omero ngff --- app/models/binary/explore/NgffExplorer.scala | 48 +++++++++++++++++-- .../com/scalableminds/util/image/Color.scala | 11 ++++- .../datareaders/zarr/NgffMetadata.scala | 14 +++++- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/app/models/binary/explore/NgffExplorer.scala b/app/models/binary/explore/NgffExplorer.scala index 5edc36338d2..168f984f8e9 100644 --- a/app/models/binary/explore/NgffExplorer.scala +++ b/app/models/binary/explore/NgffExplorer.scala @@ -1,13 +1,16 @@ package models.binary.explore import com.scalableminds.util.geometry.{Vec3Double, Vec3Int} -import com.scalableminds.util.tools.Fox +import com.scalableminds.util.image.Color +import com.scalableminds.util.tools.{Fox, TextUtils} import com.scalableminds.webknossos.datastore.dataformats.MagLocator import com.scalableminds.webknossos.datastore.dataformats.zarr.{ZarrDataLayer, ZarrLayer, ZarrSegmentationLayer} import com.scalableminds.webknossos.datastore.datareaders.AxisOrder import com.scalableminds.webknossos.datastore.datareaders.zarr._ import com.scalableminds.webknossos.datastore.datavault.VaultPath +import com.scalableminds.webknossos.datastore.models.datasource.LayerViewConfiguration.LayerViewConfiguration import com.scalableminds.webknossos.datastore.models.datasource.{Category, ElementClass} +import play.api.libs.json.{JsArray, JsNumber, JsValue} import scala.concurrent.ExecutionContext @@ -25,7 +28,8 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore layerLists: List[List[(ZarrLayer, Vec3Double)]] <- Fox.serialCombined(ngffHeader.multiscales)(multiscale => { for { channelCount: Int <- getNgffMultiscaleChannelCount(multiscale, remotePath) - layers <- layersFromNgffMultiscale(multiscale, remotePath, credentialId, channelCount) + channelAttributes = getChannelAttributes(ngffHeader) + layers <- layersFromNgffMultiscale(multiscale, remotePath, credentialId, channelCount, channelAttributes) } yield layers }) layers: List[(ZarrLayer, Vec3Double)] = layerLists.flatten @@ -48,6 +52,7 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore remotePath: VaultPath, credentialId: Option[String], channelCount: Int, + channelAttributes: Option[Seq[(Option[Color], Option[String])]] = None, isSegmentation: Boolean = false): Fox[List[(ZarrLayer, Vec3Double)]] = for { axisOrder <- extractAxisOrder(multiscale.axes) ?~> "Could not extract XYZ axis order mapping. Does the data have x, y and z axes, stated in multiscales metadata?" @@ -66,18 +71,51 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore elementClassRaw <- elementClassFromMags(magsWithAttributes) ?~> "Could not extract element class from mags" elementClass = if (isSegmentation) ensureElementClassForSegmentationLayer(elementClassRaw) else elementClassRaw + + (viewConfig: LayerViewConfiguration, channelName: String) = channelAttributes match { + case Some(attributes) => { + val color = attributes(channelIndex)._1 + val attributeName: String = + attributes(channelIndex)._2 + .map(TextUtils.normalizeStrong(_).getOrElse(name).replaceAll(" ", "")) + .getOrElse(name) + (color match { + case Some(c) => Seq(("color" -> JsArray(c.toArrayOfInts.map(i => JsNumber(BigDecimal(i)))))).toMap + case None => Map() + }, attributeName) + } + case None => (Map[String, JsValue](), name) + } + boundingBox = boundingBoxFromMags(magsWithAttributes) layer: ZarrLayer = if (looksLikeSegmentationLayer(name, elementClass) || isSegmentation) { - ZarrSegmentationLayer(name, + ZarrSegmentationLayer(channelName, boundingBox, elementClass, magsWithAttributes.map(_.mag), - largestSegmentId = None) - } else ZarrDataLayer(name, Category.color, boundingBox, elementClass, magsWithAttributes.map(_.mag)) + largestSegmentId = None, + defaultViewConfiguration = Some(viewConfig)) + } else + ZarrDataLayer(channelName, + Category.color, + boundingBox, + elementClass, + magsWithAttributes.map(_.mag), + defaultViewConfiguration = Some(viewConfig)) } yield (layer, voxelSizeNanometers) }) } yield layerTuples + private def getChannelAttributes( + ngffHeader: NgffMetadata + ): Option[Seq[(Option[Color], Option[String])]] = + ngffHeader.omero match { + case Some(value) => + Some(value.channels.map(omeroChannelAttributes => + (omeroChannelAttributes.color.map(Color.fromHTML), omeroChannelAttributes.label))) + case None => None + } + private def exploreLabelLayers(remotePath: VaultPath, credentialId: Option[String]): Fox[List[(ZarrLayer, Vec3Double)]] = for { diff --git a/util/src/main/scala/com/scalableminds/util/image/Color.scala b/util/src/main/scala/com/scalableminds/util/image/Color.scala index d399c4f2f5f..11a546cfa6f 100644 --- a/util/src/main/scala/com/scalableminds/util/image/Color.scala +++ b/util/src/main/scala/com/scalableminds/util/image/Color.scala @@ -5,7 +5,8 @@ import play.api.libs.json.Json._ import play.api.libs.json.{Format, JsValue, _} case class Color(r: Double, g: Double, b: Double, a: Double) { - def toHtml = "#%02x%02x%02x".format((r * 255).toInt, (g * 255).toInt, (b * 255).toInt) + def toHtml: String = "#%02x%02x%02x".format((r * 255).toInt, (g * 255).toInt, (b * 255).toInt) + def toArrayOfInts: Array[Int] = Array((r * 255).toInt, (g * 255).toInt, (b * 255).toInt) } object Color { @@ -21,6 +22,14 @@ object Color { ) } + def fromHTML(htmlCode: String): Color = { + val code = if (!htmlCode.startsWith("#")) s"#$htmlCode" else htmlCode + val r = Integer.valueOf(code.substring(1, 3), 16) / 255d + val g = Integer.valueOf(code.substring(3, 5), 16) / 255d + val b = Integer.valueOf(code.substring(5, 7), 16) / 255d + Color(r, g, b, 0) + } + implicit object ColorFormat extends Format[Color] { def writes(c: Color) = if (c.a == 1) Json.arr(c.r, c.g, c.b) else Json.arr(c.r, c.g, c.b, c.a) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala index 98889422f87..e07ac0c4b3a 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala @@ -84,7 +84,7 @@ object NgffMultiscalesItem { implicit val jsonFormat: OFormat[NgffMultiscalesItem] = Json.format[NgffMultiscalesItem] } -case class NgffMetadata(multiscales: List[NgffMultiscalesItem]) +case class NgffMetadata(multiscales: List[NgffMultiscalesItem], omero: Option[NgffOmeroObject]) object NgffMetadata { def fromNameScaleAndMags(dataLayerName: String, dataSourceScale: Vec3Double, mags: List[Vec3Int]): NgffMetadata = { @@ -93,7 +93,7 @@ object NgffMetadata { NgffDataset(path = mag.toMagLiteral(allowScalar = true), List(NgffCoordinateTransformation( scale = Some(List[Double](1.0) ++ (dataSourceScale * Vec3Double(mag)).toList))))) - NgffMetadata(multiscales = List(NgffMultiscalesItem(name = Some(dataLayerName), datasets = datasets))) + NgffMetadata(multiscales = List(NgffMultiscalesItem(name = Some(dataLayerName), datasets = datasets)), None) } implicit val jsonFormat: OFormat[NgffMetadata] = Json.format[NgffMetadata] @@ -107,3 +107,13 @@ object NgffLabelsGroup { implicit val jsonFormat: OFormat[NgffLabelsGroup] = Json.format[NgffLabelsGroup] val LABEL_PATH = "labels/.zattrs" } + +case class NgffOmeroObject(channels: List[NgffChannelAttributes]) +object NgffOmeroObject { + implicit val jsonFormat: OFormat[NgffOmeroObject] = Json.format[NgffOmeroObject] +} + +case class NgffChannelAttributes(color: Option[String], label: Option[String]) +object NgffChannelAttributes { + implicit val jsonFormat: OFormat[NgffChannelAttributes] = Json.format[NgffChannelAttributes] +} From c5daacf341304dcb4a7d89f1833e249d7f1bd62f Mon Sep 17 00:00:00 2001 From: frcroth Date: Tue, 8 Aug 2023 09:26:40 +0200 Subject: [PATCH 2/5] Add changelog --- CHANGELOG.unreleased.md | 3 ++- app/models/binary/explore/NgffExplorer.scala | 6 +++--- .../webknossos/datastore/models/datasource/DataLayer.scala | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index ed5e3b887b7..8ee1cb0731a 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -17,7 +17,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released - Added option to select multiple segments in the segment list in order to perform batch actions. [#7242](https://github.com/scalableminds/webknossos/pull/7242) ### Changed -- Small messages during annotating (e.g. “finished undo”, “applying mapping…”) are now click-through so they do not block users from selecting tools. [7239](https://github.com/scalableminds/webknossos/pull/7239) +- Small messages during annotating (e.g. “finished undo”, “applying mapping…”) are now click-through, so they do not block users from selecting tools. [#7239](https://github.com/scalableminds/webknossos/pull/7239) +- When exploring remote NGFF datasets with channels, layer names and colors are automatically imported if available in the metadata. [#7251](https://github.com/scalableminds/webknossos/pull/7251) ### Fixed - Fixed that folders could appear in the dataset search output in the dashboard. [#7232](https://github.com/scalableminds/webknossos/pull/7232) diff --git a/app/models/binary/explore/NgffExplorer.scala b/app/models/binary/explore/NgffExplorer.scala index 168f984f8e9..573527fbd69 100644 --- a/app/models/binary/explore/NgffExplorer.scala +++ b/app/models/binary/explore/NgffExplorer.scala @@ -9,8 +9,8 @@ import com.scalableminds.webknossos.datastore.datareaders.AxisOrder import com.scalableminds.webknossos.datastore.datareaders.zarr._ import com.scalableminds.webknossos.datastore.datavault.VaultPath import com.scalableminds.webknossos.datastore.models.datasource.LayerViewConfiguration.LayerViewConfiguration -import com.scalableminds.webknossos.datastore.models.datasource.{Category, ElementClass} -import play.api.libs.json.{JsArray, JsNumber, JsValue} +import com.scalableminds.webknossos.datastore.models.datasource.{Category, ElementClass, LayerViewConfiguration} +import play.api.libs.json.{JsArray, JsNumber} import scala.concurrent.ExecutionContext @@ -84,7 +84,7 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore case None => Map() }, attributeName) } - case None => (Map[String, JsValue](), name) + case None => (LayerViewConfiguration.empty, name) } boundingBox = boundingBoxFromMags(magsWithAttributes) diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/models/datasource/DataLayer.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/models/datasource/DataLayer.scala index 0a52fabf3ad..8077c97689e 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/models/datasource/DataLayer.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/models/datasource/DataLayer.scala @@ -143,6 +143,8 @@ object ElementClass extends ExtendedEnumeration { object LayerViewConfiguration { type LayerViewConfiguration = Map[String, JsValue] + def empty: LayerViewConfiguration = Map() + implicit val jsonFormat: Format[LayerViewConfiguration] = Format.of[LayerViewConfiguration] } From d99fca039d1a7455c370045e330ea32ceb526e69 Mon Sep 17 00:00:00 2001 From: frcroth Date: Tue, 8 Aug 2023 09:51:36 +0200 Subject: [PATCH 3/5] Fix warning --- app/models/binary/explore/NgffExplorer.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/binary/explore/NgffExplorer.scala b/app/models/binary/explore/NgffExplorer.scala index 573527fbd69..3b73a0644a4 100644 --- a/app/models/binary/explore/NgffExplorer.scala +++ b/app/models/binary/explore/NgffExplorer.scala @@ -81,7 +81,7 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore .getOrElse(name) (color match { case Some(c) => Seq(("color" -> JsArray(c.toArrayOfInts.map(i => JsNumber(BigDecimal(i)))))).toMap - case None => Map() + case None => LayerViewConfiguration.empty }, attributeName) } case None => (LayerViewConfiguration.empty, name) From 1b2b8be051dd3585e66465b64f949951d139a562 Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 21 Aug 2023 10:00:17 +0200 Subject: [PATCH 4/5] Improve naming --- MIGRATIONS.unreleased.md | 2 +- app/models/binary/explore/NgffExplorer.scala | 12 +++++++----- .../datastore/datareaders/zarr/NgffMetadata.scala | 8 ++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index 853d6848789..4f880bb7fce 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -16,7 +16,7 @@ UPDATE webknossos.multiUsers SET isEmailVerified = false; - When interacting with webknossos via the python library, make sure you update to the latest version, as the task and project api have changed. Compare [webknossos-libs#930](https://github.com/scalableminds/webknossos-libs/pull/930). [#7220](https://github.com/scalableminds/webknossos/pull/7220) - - If you have OIDC authentication set up, you can now remove the config keys `singleSignOn.openIdConnect.publicKey` and `singleSignOn.openIdConnect.publicKeyAlgorithm`, as the server’s public key is now automatically fetched. [7267](https://github.com/scalableminds/webknossos/pull/7267) + - If you have OIDC authentication set up, you can now remove the config keys `singleSignOn.openIdConnect.publicKey` and `singleSignOn.openIdConnect.publicKeyAlgorithm`, as the server’s public key is now automatically fetched. [#7267](https://github.com/scalableminds/webknossos/pull/7267) ### Postgres Evolutions: - [105-verify-email.sql](conf/evolutions/105-verify-email.sql) diff --git a/app/models/binary/explore/NgffExplorer.scala b/app/models/binary/explore/NgffExplorer.scala index 3b73a0644a4..b869e5ed893 100644 --- a/app/models/binary/explore/NgffExplorer.scala +++ b/app/models/binary/explore/NgffExplorer.scala @@ -52,7 +52,7 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore remotePath: VaultPath, credentialId: Option[String], channelCount: Int, - channelAttributes: Option[Seq[(Option[Color], Option[String])]] = None, + channelAttributes: Option[Seq[ChannelAttributes]] = None, isSegmentation: Boolean = false): Fox[List[(ZarrLayer, Vec3Double)]] = for { axisOrder <- extractAxisOrder(multiscale.axes) ?~> "Could not extract XYZ axis order mapping. Does the data have x, y and z axes, stated in multiscales metadata?" @@ -74,9 +74,9 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore (viewConfig: LayerViewConfiguration, channelName: String) = channelAttributes match { case Some(attributes) => { - val color = attributes(channelIndex)._1 + val color = attributes(channelIndex).color val attributeName: String = - attributes(channelIndex)._2 + attributes(channelIndex).name .map(TextUtils.normalizeStrong(_).getOrElse(name).replaceAll(" ", "")) .getOrElse(name) (color match { @@ -106,13 +106,15 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore }) } yield layerTuples + private case class ChannelAttributes(color: Option[Color], name: Option[String]) + private def getChannelAttributes( ngffHeader: NgffMetadata - ): Option[Seq[(Option[Color], Option[String])]] = + ): Option[Seq[ChannelAttributes]] = ngffHeader.omero match { case Some(value) => Some(value.channels.map(omeroChannelAttributes => - (omeroChannelAttributes.color.map(Color.fromHTML), omeroChannelAttributes.label))) + ChannelAttributes(omeroChannelAttributes.color.map(Color.fromHTML), omeroChannelAttributes.label))) case None => None } diff --git a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala index e07ac0c4b3a..f90c3f0c947 100644 --- a/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala +++ b/webknossos-datastore/app/com/scalableminds/webknossos/datastore/datareaders/zarr/NgffMetadata.scala @@ -84,7 +84,7 @@ object NgffMultiscalesItem { implicit val jsonFormat: OFormat[NgffMultiscalesItem] = Json.format[NgffMultiscalesItem] } -case class NgffMetadata(multiscales: List[NgffMultiscalesItem], omero: Option[NgffOmeroObject]) +case class NgffMetadata(multiscales: List[NgffMultiscalesItem], omero: Option[NgffOmeroMetadata]) object NgffMetadata { def fromNameScaleAndMags(dataLayerName: String, dataSourceScale: Vec3Double, mags: List[Vec3Int]): NgffMetadata = { @@ -108,9 +108,9 @@ object NgffLabelsGroup { val LABEL_PATH = "labels/.zattrs" } -case class NgffOmeroObject(channels: List[NgffChannelAttributes]) -object NgffOmeroObject { - implicit val jsonFormat: OFormat[NgffOmeroObject] = Json.format[NgffOmeroObject] +case class NgffOmeroMetadata(channels: List[NgffChannelAttributes]) +object NgffOmeroMetadata { + implicit val jsonFormat: OFormat[NgffOmeroMetadata] = Json.format[NgffOmeroMetadata] } case class NgffChannelAttributes(color: Option[String], label: Option[String]) From 6a95562c1febf15071e48c19040d9fe768e87c1d Mon Sep 17 00:00:00 2001 From: frcroth Date: Mon, 21 Aug 2023 10:05:44 +0200 Subject: [PATCH 5/5] Make fromHTML safer --- app/models/binary/explore/NgffExplorer.scala | 6 ++++-- .../com/scalableminds/util/image/Color.scala | 16 +++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/models/binary/explore/NgffExplorer.scala b/app/models/binary/explore/NgffExplorer.scala index b869e5ed893..7813e535e87 100644 --- a/app/models/binary/explore/NgffExplorer.scala +++ b/app/models/binary/explore/NgffExplorer.scala @@ -113,8 +113,10 @@ class NgffExplorer(implicit val ec: ExecutionContext) extends RemoteLayerExplore ): Option[Seq[ChannelAttributes]] = ngffHeader.omero match { case Some(value) => - Some(value.channels.map(omeroChannelAttributes => - ChannelAttributes(omeroChannelAttributes.color.map(Color.fromHTML), omeroChannelAttributes.label))) + Some( + value.channels.map(omeroChannelAttributes => + ChannelAttributes(omeroChannelAttributes.color.map(Color.fromHTML(_).getOrElse(Color(1, 1, 1, 0))), + omeroChannelAttributes.label))) case None => None } diff --git a/util/src/main/scala/com/scalableminds/util/image/Color.scala b/util/src/main/scala/com/scalableminds/util/image/Color.scala index 11a546cfa6f..f935806d3dd 100644 --- a/util/src/main/scala/com/scalableminds/util/image/Color.scala +++ b/util/src/main/scala/com/scalableminds/util/image/Color.scala @@ -1,6 +1,7 @@ package com.scalableminds.util.image import com.scalableminds.util.tools.ExtendedTypes._ +import net.liftweb.common.Box.tryo import play.api.libs.json.Json._ import play.api.libs.json.{Format, JsValue, _} @@ -22,13 +23,14 @@ object Color { ) } - def fromHTML(htmlCode: String): Color = { - val code = if (!htmlCode.startsWith("#")) s"#$htmlCode" else htmlCode - val r = Integer.valueOf(code.substring(1, 3), 16) / 255d - val g = Integer.valueOf(code.substring(3, 5), 16) / 255d - val b = Integer.valueOf(code.substring(5, 7), 16) / 255d - Color(r, g, b, 0) - } + def fromHTML(htmlCode: String): Option[Color] = + tryo({ + val code = if (!htmlCode.startsWith("#")) s"#$htmlCode" else htmlCode + val r = Integer.valueOf(code.substring(1, 3), 16) / 255d + val g = Integer.valueOf(code.substring(3, 5), 16) / 255d + val b = Integer.valueOf(code.substring(5, 7), 16) / 255d + Color(r, g, b, 0) + }).toOption implicit object ColorFormat extends Format[Color] {