Skip to content

Commit 99e315f

Browse files
authored
[tensor-widget] Add colormap selection and Ctrl/Alt/Shift+wheel zooming (#2764)
* Motivation for features / changes * Continue developing TensorWidget * Technical description of changes * Support Ctrl/Alt/Shift + mouse wheel zooming * Add an optional field to TensorWidgetOptions: wheelZoomKey to allow specifying whether alt, ctrl or shift key activates wheel zooming. * Add jet colormap * Add colormap selection menu * Added a color bar in the value tooltip that indicates where the currently pointed-to value is within the color scale. * Screenshots of UI changes * ![image](https://user-images.githubusercontent.com/16824702/66793119-dbc0a100-eeb0-11e9-950d-a72c2599062c.png) * ![image](https://user-images.githubusercontent.com/16824702/66793166-04e13180-eeb1-11e9-83d3-123802de1247.png) * ![image](https://user-images.githubusercontent.com/16824702/66793021-5b01a500-eeb0-11e9-9abd-4c90401106b5.png) * ![image](https://user-images.githubusercontent.com/16824702/66793103-b338a700-eeb0-11e9-86a6-f8d351d88acf.png)
1 parent b4e547f commit 99e315f

File tree

7 files changed

+330
-51
lines changed

7 files changed

+330
-51
lines changed

tensorboard/components/tensor_widget/colormap-test.ts

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,36 +17,40 @@ limitations under the License.
1717

1818
import {expect} from 'chai';
1919

20-
import {GrayscaleColorMap} from './colormap';
20+
import {GrayscaleColorMap, JetColorMap} from './colormap';
2121

2222
describe('GrayscaleColorMap', () => {
2323
it('max < min causes constructor error', () => {
2424
const min = 3;
2525
const max = 2;
26-
expect(() => new GrayscaleColorMap(min, max)).to.throw(/max.*<.*min/);
26+
expect(() => new GrayscaleColorMap({min, max})).to.throw(/max.*<.*min/);
2727
});
2828

2929
it('NaN or Infinity min or max causes constructor error', () => {
30-
expect(() => new GrayscaleColorMap(0, Infinity)).to.throw(
30+
expect(() => new GrayscaleColorMap({min: 0, max: Infinity})).to.throw(
3131
/max.*not finite/
3232
);
33-
expect(() => new GrayscaleColorMap(0, -Infinity)).to.throw(
33+
expect(() => new GrayscaleColorMap({min: 0, max: -Infinity})).to.throw(
3434
/max.*not finite/
3535
);
36-
expect(() => new GrayscaleColorMap(0, NaN)).to.throw(/max.*not finite/);
37-
expect(() => new GrayscaleColorMap(Infinity, 0)).to.throw(
36+
expect(() => new GrayscaleColorMap({min: 0, max: NaN})).to.throw(
37+
/max.*not finite/
38+
);
39+
expect(() => new GrayscaleColorMap({min: Infinity, max: 0})).to.throw(
3840
/min.*not finite/
3941
);
40-
expect(() => new GrayscaleColorMap(-Infinity, 0)).to.throw(
42+
expect(() => new GrayscaleColorMap({min: -Infinity, max: 0})).to.throw(
43+
/min.*not finite/
44+
);
45+
expect(() => new GrayscaleColorMap({min: NaN, max: 0})).to.throw(
4146
/min.*not finite/
4247
);
43-
expect(() => new GrayscaleColorMap(NaN, 0)).to.throw(/min.*not finite/);
4448
});
4549

4650
it('max > min, finite values', () => {
4751
const min = 0;
4852
const max = 10;
49-
const colormap = new GrayscaleColorMap(min, max);
53+
const colormap = new GrayscaleColorMap({min, max});
5054
expect(colormap.getRGB(0)).to.eql([0, 0, 0]);
5155
expect(colormap.getRGB(5)).to.eql([127.5, 127.5, 127.5]);
5256
expect(colormap.getRGB(10)).to.eql([255, 255, 255]);
@@ -58,7 +62,7 @@ describe('GrayscaleColorMap', () => {
5862
it('max > min, non-finite values', () => {
5963
const min = 0;
6064
const max = 10;
61-
const colormap = new GrayscaleColorMap(min, max);
65+
const colormap = new GrayscaleColorMap({min, max});
6266
expect(colormap.getRGB(NaN)).to.eql([255, 0, 0]);
6367
expect(colormap.getRGB(-Infinity)).to.eql([255, 255 / 2, 0]);
6468
expect(colormap.getRGB(Infinity)).to.eql([0, 0, 255]);
@@ -67,7 +71,7 @@ describe('GrayscaleColorMap', () => {
6771
it('max === min, non-finite values', () => {
6872
const min = -3.2;
6973
const max = -3.2;
70-
const colormap = new GrayscaleColorMap(min, max);
74+
const colormap = new GrayscaleColorMap({min, max});
7175
expect(colormap.getRGB(-32)).to.eql([127.5, 127.5, 127.5]);
7276
expect(colormap.getRGB(-3.2)).to.eql([127.5, 127.5, 127.5]);
7377
expect(colormap.getRGB(0)).to.eql([127.5, 127.5, 127.5]);
@@ -77,3 +81,61 @@ describe('GrayscaleColorMap', () => {
7781
expect(colormap.getRGB(Infinity)).to.eql([0, 0, 255]);
7882
});
7983
});
84+
85+
describe('JetColormap', () => {
86+
it('max < min causes constructor error', () => {
87+
const min = 3;
88+
const max = 2;
89+
expect(() => new JetColorMap({min, max})).to.throw(/max.*<.*min/);
90+
});
91+
92+
it('NaN or Infinity min or max causes constructor error', () => {
93+
expect(() => new JetColorMap({min: 0, max: Infinity})).to.throw(
94+
/max.*not finite/
95+
);
96+
expect(() => new JetColorMap({min: 0, max: -Infinity})).to.throw(
97+
/max.*not finite/
98+
);
99+
expect(() => new JetColorMap({min: 0, max: NaN})).to.throw(
100+
/max.*not finite/
101+
);
102+
expect(() => new JetColorMap({min: Infinity, max: 0})).to.throw(
103+
/min.*not finite/
104+
);
105+
expect(() => new JetColorMap({min: -Infinity, max: 0})).to.throw(
106+
/min.*not finite/
107+
);
108+
expect(() => new JetColorMap({min: NaN, max: 0})).to.throw(
109+
/min.*not finite/
110+
);
111+
});
112+
113+
it('max > min, finite values', () => {
114+
const min = 0;
115+
const max = 10;
116+
const colormap = new JetColorMap({min, max});
117+
expect(colormap.getRGB(0)).to.eql([0, 0, 255]);
118+
expect(colormap.getRGB(5)).to.eql([127.5, 255, 127.5]);
119+
expect(colormap.getRGB(10)).to.eql([255, 0, 0]);
120+
// Over-limits.
121+
expect(colormap.getRGB(-100)).to.eql([0, 0, 255]);
122+
expect(colormap.getRGB(500)).to.eql([255, 0, 0]);
123+
});
124+
125+
it('max > min, non-finite values', () => {
126+
const min = 0;
127+
const max = 10;
128+
const colormap = new JetColorMap({min, max});
129+
expect(colormap.getRGB(NaN)).to.eql([255 * 0.25, 255 * 0.25, 255 * 0.25]);
130+
expect(colormap.getRGB(-Infinity)).to.eql([
131+
255 * 0.5,
132+
255 * 0.5,
133+
255 * 0.5,
134+
]);
135+
expect(colormap.getRGB(Infinity)).to.eql([
136+
255 * 0.75,
137+
255 * 0.75,
138+
255 * 0.75,
139+
]);
140+
});
141+
});

tensorboard/components/tensor_widget/colormap.ts

Lines changed: 133 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ limitations under the License.
1717

1818
const MAX_RGB = 255;
1919

20+
/** Configuration for a colormap. */
21+
export interface ColorMapConfig {
22+
/**
23+
* Minimum value that the color map can map to without clipping.
24+
* Must be a finite value.
25+
*/
26+
min: number;
27+
28+
/**
29+
* Minimum value that the color map can map to without clipping.
30+
* Must be a finite value and be `>= min`.
31+
*
32+
* In the case of `max === min`, all finite values mapped to the
33+
* midpoint of the color scale.
34+
*/
35+
max: number;
36+
}
37+
2038
/**
2139
* Abstract base class for colormap.
2240
*
@@ -28,15 +46,15 @@ export abstract class ColorMap {
2846
* @param min Minimum. Must be a finite value.
2947
* @param max Maximum. Must be finite and >= `min`.
3048
*/
31-
constructor(protected readonly min: number, protected readonly max: number) {
32-
if (!isFinite(min)) {
33-
throw new Error(`min value (${min}) is not finite`);
49+
constructor(protected config: ColorMapConfig) {
50+
if (!isFinite(config.min)) {
51+
throw new Error(`min value (${config.min}) is not finite`);
3452
}
35-
if (!isFinite(max)) {
36-
throw new Error(`max value (${max}) is not finite`);
53+
if (!isFinite(config.max)) {
54+
throw new Error(`max value (${config.max}) is not finite`);
3755
}
38-
if (max < min) {
39-
throw new Error(`max (${max}) is < min (${min})`);
56+
if (config.max < config.min) {
57+
throw new Error(`max (${config.max}) is < min (${config.min})`);
4058
}
4159
}
4260

@@ -47,6 +65,67 @@ export abstract class ColorMap {
4765
* The range of RGB values is [0, 255].
4866
*/
4967
abstract getRGB(value: number): [number, number, number];
68+
69+
/**
70+
* Render the colormap as a horizontal scale, while highlighting a specifc
71+
* value.
72+
*
73+
* The value is highlighted if and only if it is `>= this.config.min` and
74+
* `<= this.config.max`.
75+
*
76+
* @param canvas The canvas on which
77+
* @param value The number to highlight (optional).
78+
*/
79+
render(canvas: HTMLCanvasElement, value?: number) {
80+
if (this.config.min === this.config.max) {
81+
return;
82+
}
83+
const context = canvas.getContext('2d');
84+
if (context == null) {
85+
return;
86+
}
87+
const steps = 100;
88+
const cellWidth = canvas.width / steps;
89+
const height = canvas.height;
90+
const verticalMargin = 0.2;
91+
const barHeight = height * (1 - 2 * verticalMargin);
92+
for (let i = 0; i < steps; ++i) {
93+
const value =
94+
(this.config.max - this.config.min) * (i / steps) + this.config.min;
95+
const x = cellWidth * i;
96+
const y = height * verticalMargin;
97+
const [r, g, b] = this.getRGB(value);
98+
context.beginPath();
99+
context.fillStyle = `rgba(${r}, ${g}, ${b}, 1)`;
100+
context.fillRect(x, y, cellWidth, barHeight);
101+
context.stroke();
102+
}
103+
104+
if (value != null) {
105+
const tickWidth = 8;
106+
if (value >= this.config.min && value <= this.config.max) {
107+
// Highlight the relative position of `value` along the color scale.
108+
const tickX =
109+
((value - this.config.min) / (this.config.max - this.config.min)) *
110+
canvas.width;
111+
112+
// Draw the triangle on the top.
113+
context.beginPath();
114+
context.fillStyle = 'rgba(0, 0, 0, 1)';
115+
context.moveTo(tickX, verticalMargin * height);
116+
context.lineTo(tickX - tickWidth / 2, 0);
117+
context.lineTo(tickX + tickWidth / 2, 0);
118+
context.fill();
119+
120+
// Draw the triangle on the bottom.
121+
context.beginPath();
122+
context.moveTo(tickX, (1 - verticalMargin) * height);
123+
context.lineTo(tickX - tickWidth / 2, height);
124+
context.lineTo(tickX + tickWidth / 2, height);
125+
context.fill();
126+
}
127+
}
128+
}
50129
}
51130

52131
/**
@@ -68,13 +147,52 @@ export class GrayscaleColorMap extends ColorMap {
68147
return [MAX_RGB, MAX_RGB / 2, 0];
69148
}
70149
}
71-
let relativeValue =
72-
this.min === this.max ? 0.5 : (value - this.min) / (this.max - this.min);
73-
relativeValue = Math.max(Math.min(relativeValue, 1), 0);
74-
return [
75-
MAX_RGB * relativeValue,
76-
MAX_RGB * relativeValue,
77-
MAX_RGB * relativeValue,
78-
];
150+
let relValue =
151+
this.config.min === this.config.max
152+
? 0.5
153+
: (value - this.config.min) / (this.config.max - this.config.min);
154+
relValue = Math.max(Math.min(relValue, 1), 0);
155+
return [MAX_RGB * relValue, MAX_RGB * relValue, MAX_RGB * relValue];
156+
}
157+
}
158+
159+
export class JetColorMap extends ColorMap {
160+
getRGB(value: number): [number, number, number] {
161+
if (isNaN(value)) {
162+
// NaN.
163+
return [MAX_RGB * 0.25, MAX_RGB * 0.25, MAX_RGB * 0.25];
164+
} else if (!isFinite(value)) {
165+
if (value < 0) {
166+
// -Infinity.
167+
return [MAX_RGB * 0.5, MAX_RGB * 0.5, MAX_RGB * 0.5];
168+
} else {
169+
// +Infinity.
170+
return [MAX_RGB * 0.75, MAX_RGB * 0.75, MAX_RGB * 0.75];
171+
}
172+
}
173+
174+
let relR = 0;
175+
let relG = 0;
176+
let relB = 0;
177+
const lim0 = 0.35;
178+
const lim1 = 0.65;
179+
180+
let relValue =
181+
this.config.min === this.config.max
182+
? 0.5
183+
: (value - this.config.min) / (this.config.max - this.config.min);
184+
relValue = Math.max(Math.min(relValue, 1), 0);
185+
if (relValue <= lim0) {
186+
relG = relValue / lim0;
187+
relB = 1;
188+
} else if (relValue > lim0 && relValue <= lim1) {
189+
relR = (relValue - lim0) / (lim1 - lim0);
190+
relG = 1;
191+
relB = (lim1 - relValue) / (lim1 - lim0);
192+
} else if (relValue > lim1) {
193+
relR = 1;
194+
relG = (1 - relValue) / (1 - lim1);
195+
}
196+
return [relR * MAX_RGB, relG * MAX_RGB, relB * MAX_RGB];
79197
}
80198
}

tensorboard/components/tensor_widget/menu.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ type EventCallback = (event: Event) => void | Promise<void>;
2121
export interface MenuItemConfig {
2222
/** Caption displayed on the menu item. */
2323
caption: string;
24+
25+
/**
26+
* A function that determines whether menu item is currently enabled.
27+
*
28+
* If not provided, the menu item will always be enabled.
29+
*/
30+
isEnabled?: () => boolean;
2431
}
2532

2633
/**
@@ -29,13 +36,6 @@ export interface MenuItemConfig {
2936
export interface SingleActionMenuItemConfig extends MenuItemConfig {
3037
/** The callback that gets called when the menu item is clicked. */
3138
callback: EventCallback;
32-
33-
/**
34-
* A function that determines whether menu item is currently enabled.
35-
*
36-
* If not provided, the menu item will always be enabled.
37-
*/
38-
isEnabled?: () => boolean;
3939
}
4040

4141
export interface ChoiceMenuItemConfig extends MenuItemConfig {
@@ -267,12 +267,9 @@ export class Menu {
267267
// This is a single-command item.
268268
const singleActionConfig = item as SingleActionMenuItemConfig;
269269
outerItemConfig.onClick = singleActionConfig.callback;
270-
if (
271-
singleActionConfig.isEnabled != null &&
272-
!singleActionConfig.isEnabled()
273-
) {
274-
outerItemConfig.disabled = true;
275-
}
270+
}
271+
if (item.isEnabled != null && !item.isEnabled()) {
272+
outerItemConfig.disabled = true;
276273
}
277274
outerItemConfigs.push(outerItemConfig);
278275
});

0 commit comments

Comments
 (0)