diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index c9d731c153f..413012a4a84 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -12,6 +12,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added +- Added the possibility to adjust the minimum and maximum value of the histogram for a layer. This option can be opened in the top right corner of the histogram. [#4630](https://github.com/scalableminds/webknossos/pull/4630) + - Added a warning to the segmentation tab when viewing `uint64` bit segmentation data. [#4598](https://github.com/scalableminds/webknossos/pull/4598) - Added the possibility to have multiple user-defined bounding boxes in an annotation. Task bounding boxes are automatically converted to such user bounding boxes upon “copy to my account” / reupload as explorational annotation. [#4536](https://github.com/scalableminds/webknossos/pull/4536) - Added additional information to each task in CSV download. [#4647](https://github.com/scalableminds/webknossos/pull/4647) diff --git a/frontend/javascripts/oxalis/model/reducers/settings_reducer.js b/frontend/javascripts/oxalis/model/reducers/settings_reducer.js index cdc26f50023..f336b12f45c 100644 --- a/frontend/javascripts/oxalis/model/reducers/settings_reducer.js +++ b/frontend/javascripts/oxalis/model/reducers/settings_reducer.js @@ -98,6 +98,7 @@ function SettingsReducer(state: OxalisState, action: Action): OxalisState { intensityRange, isDisabled: false, isInverted: false, + isInEditMode: false, }, initialLayerSettings[layer.name], ); diff --git a/frontend/javascripts/oxalis/model/sagas/load_histogram_data_saga.js b/frontend/javascripts/oxalis/model/sagas/load_histogram_data_saga.js index 08732828aaa..57f9453cb03 100644 --- a/frontend/javascripts/oxalis/model/sagas/load_histogram_data_saga.js +++ b/frontend/javascripts/oxalis/model/sagas/load_histogram_data_saga.js @@ -54,6 +54,14 @@ export default function* loadHistogramData(): Saga { newIntensityRange = [minimumInHistogramData, maximumInHistogramData]; } yield* put(updateLayerSettingAction(layerName, "intensityRange", newIntensityRange)); + // Here we also set the minium and maximum values for the intensity range that the user can enter. + // If values already exist, we skip this step. + if (layerConfigurations[layerName] == null || layerConfigurations[layerName].min == null) { + yield* put(updateLayerSettingAction(layerName, "min", minimumInHistogramData)); + } + if (layerConfigurations[layerName] == null || layerConfigurations[layerName].max == null) { + yield* put(updateLayerSettingAction(layerName, "max", maximumInHistogramData)); + } } yield* put(setHistogramDataAction(histograms)); } diff --git a/frontend/javascripts/oxalis/store.js b/frontend/javascripts/oxalis/store.js index c602634ebaf..0fd3e19ed0c 100644 --- a/frontend/javascripts/oxalis/store.js +++ b/frontend/javascripts/oxalis/store.js @@ -251,8 +251,11 @@ export type DatasetLayerConfiguration = {| +contrast: number, +alpha: number, +intensityRange: Vector2, + +min?: number, + +max?: number, +isDisabled: boolean, +isInverted: boolean, + +isInEditMode: boolean, |}; export type LoadingStrategy = "BEST_QUALITY_FIRST" | "PROGRESSIVE_QUALITY"; diff --git a/frontend/javascripts/oxalis/view/settings/dataset_settings_view.js b/frontend/javascripts/oxalis/view/settings/dataset_settings_view.js index fb993440616..d841b149aa5 100644 --- a/frontend/javascripts/oxalis/view/settings/dataset_settings_view.js +++ b/frontend/javascripts/oxalis/view/settings/dataset_settings_view.js @@ -136,6 +136,27 @@ class DatasetSettings extends React.PureComponent { ); }; + getEditMinMaxButton = (layerName: string, isInEditMode: boolean) => { + const tooltipText = isInEditMode + ? "Stop editing the possible range of the histogram." + : "Manually set the possible range of the histogram."; + return ( + + this.props.onChangeLayer(layerName, "isInEditMode", !isInEditMode)} + style={{ + position: "absolute", + top: 4, + right: 30, + cursor: "pointer", + color: isInEditMode ? "rgb(24, 144, 255)" : null, + }} + /> + + ); + }; + setVisibilityForAllLayers = (isVisible: boolean) => { const { layers } = this.props.datasetConfiguration; Object.keys(layers).forEach(otherLayerName => @@ -165,9 +186,8 @@ class DatasetSettings extends React.PureComponent { ); getHistogram = (layerName: string, layer: DatasetLayerConfiguration) => { - const { intensityRange } = layer; + const { intensityRange, min, max, isInEditMode } = layer; const defaultIntensityRange = getDefaultIntensityRangeOfLayer(this.props.dataset, layerName); - const highestRangeValue = defaultIntensityRange[1]; let histograms = []; if (this.props.histogramData && this.props.histogramData[layerName]) { histograms = this.props.histogramData[layerName]; @@ -176,8 +196,8 @@ class DatasetSettings extends React.PureComponent { { numberOfElements: 0, elementCounts: [], - min: 0, - max: highestRangeValue, + min: defaultIntensityRange[0], + max: defaultIntensityRange[1], }, ]; } @@ -186,7 +206,11 @@ class DatasetSettings extends React.PureComponent { data={histograms} intensityRangeMin={intensityRange[0]} intensityRangeMax={intensityRange[1]} + min={min} + max={max} + isInEditMode={isInEditMode} layerName={layerName} + defaultMinMax={defaultIntensityRange} /> ); }; @@ -194,6 +218,7 @@ class DatasetSettings extends React.PureComponent { getLayerSettingsHeader = ( isDisabled: boolean, isColorLayer: boolean, + isInEditMode: boolean, layerName: string, elementClass: string, ) => { @@ -216,6 +241,7 @@ class DatasetSettings extends React.PureComponent { setSingleLayerVisibility(true); } }; + const hasHistogram = this.props.histogramData[layerName] != null; return ( @@ -225,6 +251,7 @@ class DatasetSettings extends React.PureComponent { {!isColorLayer && isVolumeTracing ? "Volume Layer" : layerName} {elementClass} + {hasHistogram ? this.getEditMinMaxButton(layerName, isInEditMode) : null} {this.getFindDataButton(layerName, isDisabled, isColorLayer)} {this.getReloadDataButton(layerName)} @@ -242,11 +269,17 @@ class DatasetSettings extends React.PureComponent { return null; } const elementClass = getElementClass(this.props.dataset, layerName); - const { isDisabled } = layerConfiguration; + const { isDisabled, isInEditMode } = layerConfiguration; return (
- {this.getLayerSettingsHeader(isDisabled, isColorLayer, layerName, elementClass)} + {this.getLayerSettingsHeader( + isDisabled, + isColorLayer, + isInEditMode, + layerName, + elementClass, + )} {isDisabled ? null : ( {isHistogramSupported(elementClass) && layerName != null && isColorLayer diff --git a/frontend/javascripts/oxalis/view/settings/histogram_view.js b/frontend/javascripts/oxalis/view/settings/histogram_view.js index 675dd316b10..baab150b4ac 100644 --- a/frontend/javascripts/oxalis/view/settings/histogram_view.js +++ b/frontend/javascripts/oxalis/view/settings/histogram_view.js @@ -1,6 +1,6 @@ // @flow -import { Slider } from "antd"; +import { Slider, Row, Col, InputNumber, Tooltip } from "antd"; import * as _ from "lodash"; import * as React from "react"; import type { Dispatch } from "redux"; @@ -9,7 +9,7 @@ import { type DatasetLayerConfiguration } from "oxalis/store"; import { updateLayerSettingAction } from "oxalis/model/actions/settings_actions"; import { type ElementClass } from "admin/api_flow_types"; import type { APIHistogramData } from "admin/api_flow_types"; -import { type Vector3 } from "oxalis/constants"; +import type { Vector3, Vector2 } from "oxalis/constants"; import { roundTo } from "libs/utils"; type OwnProps = {| @@ -17,6 +17,10 @@ type OwnProps = {| layerName: string, intensityRangeMin: number, intensityRangeMax: number, + min?: number, + max?: number, + isInEditMode: boolean, + defaultMinMax: Vector2, |}; type HistogramProps = { @@ -24,21 +28,47 @@ type HistogramProps = { onChangeLayer: ( layerName: string, propertyName: $Keys, - value: [number, number], + value: [number, number] | number, ) => void, }; +type HistogramState = { + currentMin: number, + currentMax: number, +}; + const uint24Colors = [[255, 65, 54], [46, 204, 64], [24, 144, 255]]; const canvasHeight = 100; const canvasWidth = 300; +function isANumber(value: number | string): boolean { + // Code from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + return !isNaN(parseFloat(value)) && isFinite(value); +} + export function isHistogramSupported(elementClass: ElementClass): boolean { return ["int8", "uint8", "int16", "uint16", "float", "uint24"].includes(elementClass); } -class Histogram extends React.PureComponent { +function getMinAndMax(props: HistogramProps) { + const { min, max, data } = props; + if (min != null && max != null) { + return { min, max }; + } + const dataMin = Math.min(...data.map(({ min: minOfHistPart }) => minOfHistPart)); + const dataMax = Math.max(...data.map(({ max: maxOfHistPart }) => maxOfHistPart)); + return { min: dataMin, max: dataMax }; +} + +class Histogram extends React.PureComponent { canvasRef: ?HTMLCanvasElement; + constructor(props: HistogramProps) { + super(props); + const { min, max } = getMinAndMax(props); + this.state = { currentMin: min, currentMax: max }; + } + componentDidMount() { if (this.canvasRef == null) { return; @@ -51,6 +81,11 @@ class Histogram extends React.PureComponent { this.updateCanvas(); } + componentWillReceiveProps(newProps: HistogramProps) { + const { min, max } = getMinAndMax(newProps); + this.setState({ currentMin: min, currentMax: max }); + } + componentDidUpdate() { this.updateCanvas(); } @@ -61,12 +96,25 @@ class Histogram extends React.PureComponent { } const ctx = this.canvasRef.getContext("2d"); ctx.clearRect(0, 0, canvasWidth, canvasHeight); + const { min, max } = getMinAndMax(this.props); const { data } = this.props; - // Compute the overall maximum count, so the RGB curves are scaled correctly relative to each other - const maxValue = Math.max(...data.map(({ elementCounts }) => Math.max(...elementCounts))); + // Compute the overall maximum count, so the RGB curves are scaled correctly relative to each other. + const maxValue = Math.max( + ...data.map(({ elementCounts, min: histogramMin, max: histogramMax }) => { + // We only take the elements of the array into account that are displayed. + const displayedPartStartOffset = Math.max(histogramMin, min) - histogramMin; + const displayedPartEndOffset = Math.min(histogramMax, max) - histogramMin; + // Here we scale the offsets to the range of the elements array. + const startingIndex = + (displayedPartStartOffset * elementCounts.length) / (histogramMax - histogramMin); + const endingIndex = + (displayedPartEndOffset * elementCounts.length) / (histogramMax - histogramMin); + return Math.max(...elementCounts.slice(startingIndex, endingIndex + 1)); + }), + ); for (const [i, histogram] of data.entries()) { const color = this.props.data.length > 1 ? uint24Colors[i] : uint24Colors[2]; - this.drawHistogram(ctx, histogram, maxValue, color); + this.drawHistogram(ctx, histogram, maxValue, color, min, max); } } @@ -75,10 +123,14 @@ class Histogram extends React.PureComponent { histogram: $ElementType, maxValue: number, color: Vector3, + minRange: number, + maxRange: number, ) => { const { intensityRangeMin, intensityRangeMax } = this.props; - const { min: minRange, max: maxRange, elementCounts } = histogram; - const rangeLength = maxRange - minRange; + const { min: histogramMin, max: histogramMax, elementCounts } = histogram; + const histogramLength = histogramMax - histogramMin; + const fullLength = maxRange - minRange; + const xOffset = histogramMin - minRange; this.drawYAxis(ctx); ctx.fillStyle = `rgba(${color.join(",")}, 0.1)`; ctx.strokeStyle = `rgba(${color.join(",")})`; @@ -89,20 +141,23 @@ class Histogram extends React.PureComponent { value => Math.log10(downscalingFactor * value + 1) * canvasHeight, ); const activeRegion = new Path2D(); - ctx.moveTo(0, downscaledData[0]); - activeRegion.moveTo(((intensityRangeMin - minRange) / rangeLength) * canvasWidth, 0); + ctx.moveTo(0, 0); + activeRegion.moveTo(((intensityRangeMin - minRange) / fullLength) * canvasWidth, 0); for (let i = 0; i < downscaledData.length; i++) { - const x = (i / downscaledData.length) * canvasWidth; - const xValue = minRange + i * (rangeLength / downscaledData.length); + const xInHistogramScale = (i * histogramLength) / downscaledData.length; + const xInCanvasScale = ((xOffset + xInHistogramScale) * canvasWidth) / fullLength; + const xValue = histogramMin + xInHistogramScale; if (xValue >= intensityRangeMin && xValue <= intensityRangeMax) { - activeRegion.lineTo(x, downscaledData[i]); + activeRegion.lineTo(xInCanvasScale, downscaledData[i]); } - ctx.lineTo(x, downscaledData[i]); + ctx.lineTo(xInCanvasScale, downscaledData[i]); } ctx.stroke(); ctx.closePath(); - activeRegion.lineTo(((intensityRangeMax - minRange) / rangeLength) * canvasWidth, 0); - activeRegion.lineTo(((intensityRangeMin - minRange) / rangeLength) * canvasWidth, 0); + const activeRegionRightLimit = Math.min(histogramMax, intensityRangeMax); + const activeRegionLeftLimit = Math.max(histogramMin, intensityRangeMin); + activeRegion.lineTo(((activeRegionRightLimit - minRange) / fullLength) * canvasWidth, 0); + activeRegion.lineTo(((activeRegionLeftLimit - minRange) / fullLength) * canvasWidth, 0); activeRegion.closePath(); ctx.fill(activeRegion); }; @@ -132,9 +187,41 @@ class Histogram extends React.PureComponent { } }; + tipFormatter = (value: number) => + value > 10000 ? value.toExponential() : roundTo(value, 2).toString(); + + // eslint-disable-next-line react/sort-comp + updateMinimumDebounced = _.debounce( + (value, layerName) => this.props.onChangeLayer(layerName, "min", value), + 500, + ); + + updateMaximumDebounced = _.debounce( + (value, layerName) => this.props.onChangeLayer(layerName, "max", value), + 500, + ); + render() { - const { intensityRangeMin, intensityRangeMax, data } = this.props; - const { min: minRange, max: maxRange } = data[0]; + const { + intensityRangeMin, + intensityRangeMax, + isInEditMode, + defaultMinMax, + layerName, + } = this.props; + const { currentMin, currentMax } = this.state; + const { min: minRange, max: maxRange } = getMinAndMax(this.props); + const formatValue = (newValue: string): number | string => { + if (!isANumber(newValue)) { + return newValue; + } + return roundTo(parseFloat(newValue), 2); + }; + const tooltipTitleFor = (minimumOrMaximum: string) => + `Enter the ${minimumOrMaximum} possible value for layer ${layerName}. Scientific (e.g. 9e+10) notation is supported.`; + + const minMaxInputStyle = { width: "100%" }; + return ( { height={canvasHeight} /> roundTo(val, 2).toString()} + tipFormatter={this.tipFormatter} + style={{ width: canvasWidth, margin: 0, marginBottom: 18 }} /> + {isInEditMode ? ( + + + + + + + { + value = parseFloat(value); + if (isANumber(value) && value <= maxRange) { + this.setState({ currentMin: value }); + this.updateMinimumDebounced(value, layerName); + } + }} + style={minMaxInputStyle} + /> + + + + + + + + { + value = parseFloat(value); + if (isANumber(value) && value >= minRange) { + this.setState({ currentMax: value }); + this.updateMaximumDebounced(value, layerName); + } + }} + style={minMaxInputStyle} + /> + + + + ) : null} ); } diff --git a/frontend/javascripts/oxalis/view/settings/setting_input_views.js b/frontend/javascripts/oxalis/view/settings/setting_input_views.js index 1828c234239..d9c5291ce26 100644 --- a/frontend/javascripts/oxalis/view/settings/setting_input_views.js +++ b/frontend/javascripts/oxalis/view/settings/setting_input_views.js @@ -102,7 +102,12 @@ export class LogSliderSetting extends React.PureComponent return Math.exp((value - b) / a); } - formatTooltip = (value: number) => Utils.roundTo(this.calculateValue(value), this.props.roundTo); + formatTooltip = (value: number) => { + const calculatedValue = this.calculateValue(value); + return calculatedValue >= 10000 + ? calculatedValue.toExponential() + : Utils.roundTo(calculatedValue, this.props.roundTo); + }; getSliderValue = () => { const a = 200 / (Math.log(this.props.max) - Math.log(this.props.min)); diff --git a/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js b/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js index 75299272a11..c829221f190 100644 --- a/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js +++ b/frontend/javascripts/test/puppeteer/dataset_rendering.screenshot.js @@ -98,8 +98,11 @@ const datasetConfigOverrides: { [key: string]: DatasetConfiguration } = { brightness: 0, alpha: 100, intensityRange: [0, 255], + min: 0, + max: 255, isDisabled: false, isInverted: false, + isInEditMode: false, }, }, quality: 0,