Skip to content

Commit 01a7e4c

Browse files
Merge branch 'dev' into Final-work-of-data-abstraction-layer
2 parents 0cfbe85 + 39ba5f5 commit 01a7e4c

25 files changed

Lines changed: 2891 additions & 154 deletions

File tree

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ Other options are either currently unused (endpoint, ipv6) or used for internal
267267
### Basics
268268
Visdom offers the following basic visualization functions:
269269
- [`vis.image`](#visimage) : image
270+
- [`vis.image_heatmap`](#visimageheatmap) : image with heatmap overlay
270271
- [`vis.images`](#visimages) : list of images
271272
- [`vis.text`](#vistext) : arbitrary HTML
272273
- [`vis.properties`](#visproperties) : properties grid
@@ -285,6 +286,7 @@ The following API is currently supported:
285286
- [`vis.scatter`](#visscatter) : 2D or 3D scatter plots
286287
- [`vis.sunburst`](#vissunburst) : sunburst (hierarchy) charts
287288
- [`vis.line`](#visline) : line plots
289+
- [`vis.learning_curve`](#vislearning_curve) : named training metric curves
288290
- [`vis.stem`](#visstem) : stem plots
289291
- [`vis.heatmap`](#visheatmap) : heatmap plots
290292
- [`vis.confusion_matrix`](#visconfusion_matrix) : confusion matrix plots
@@ -357,6 +359,34 @@ The following `opts` are supported:
357359
> **Note** You can use alt on an image pane to view the x/y coordinates of the cursor. You can also ctrl-scroll to zoom, alt scroll to pan vertically, and alt-shift scroll to pan horizontally. Double click inside the pane to restore the image to default.
358360
359361

362+
#### vis.image_heatmap
363+
364+
This function overlays a saliency or attention heatmap on top of an image. It takes a `CxHxW` or `HxW` array `img` (uint8 or float) and an `HxW` float array `heatmap` with values in `[0, 1]`. The blending is per-pixel — pixels where the heatmap is near zero stay close to the original image, so a zero-gradient background does not get tinted by the colormap.
365+
366+
```python
367+
import numpy as np
368+
from visdom import Visdom
369+
370+
viz = Visdom()
371+
372+
# img: CxHxW uint8 or float in [0, 1]
373+
# heatmap: HxW float in [0, 1] — e.g. from a saliency method or attention map
374+
viz.image_heatmap(img, heatmap, opts=dict(title="Saliency", alpha=0.6, colormap="jet"))
375+
```
376+
377+
Any attribution method that produces an `HxW` numpy array works — gradient saliency, GradCAM, SHAP, or a hand-computed attention map.
378+
379+
The following `opts` are supported:
380+
381+
- `alpha`: blend strength (`float` in `[0, 1]`; default = `0.5`). Higher values make the heatmap more visible.
382+
- `colormap`: matplotlib colormap name (`string`; default = `'jet'`). Falls back to a blue-red gradient if matplotlib is not installed.
383+
- `caption`: caption for the image pane
384+
- `jpgquality`: JPG quality (`number` 0-100). If set, the result is encoded as JPEG. Otherwise PNG.
385+
- `normalize`: normalize the image to `[0, 1]` before blending (`boolean`; default = `False`)
386+
387+
> **Note** `heatmap` accepts any finite float range. Values outside `[0, 1]` are rescaled automatically via min-max normalization, so methods like SHAP or Integrated Gradients that return signed or unnormalized values work without any pre-processing. NaN maps to 0; infinite values are clamped to the `[0, 1]` boundary.
388+
389+
360390
#### vis.images
361391

362392
This function draws a list of `images`. It takes an input `B x C x H x W` tensor or a `list of images` all of the same size. It makes a grid of images of size (B / nrow, nrow).
@@ -469,6 +499,7 @@ The function accepts the following arguments:
469499
- `labels`: a list of corresponding labels for the tensors provided for `features`
470500
- `data_getter=fn`: (optional) a function that takes as a parameter an index into the features array and returns a summary representation of the tensor. If this is set, `data_type` must also be set.
471501
- `data_type=str`: (optional) currently the only acceptable value here is `"html"`
502+
- `opts.register_embedding_events`: (optional) set to `False` to skip registering the default Python client event handler for hover previews and lasso drilldown. This leaves embeddings interaction events for external server or frontend code to handle.
472503

473504
We currently assume that there are no more than 10 unique labels, in the future we hope to provide a colormap in opts for other cases.
474505

@@ -581,6 +612,29 @@ The following `opts` are supported:
581612
- `opts.layoutopts` : additional backend layout options (`dict`)
582613

583614

615+
#### vis.learning_curve
616+
This function draws named machine-learning metrics as line plots. It accepts a mapping from metric names to scalar values or equal-length 1D series and forwards to [`vis.line`](#visline).
617+
618+
For example:
619+
620+
```python
621+
win = vis.learning_curve(
622+
{"train_loss": [1.0, 0.8, 0.6], "val_loss": [1.1, 0.9, 0.7]},
623+
step=[1, 2, 3],
624+
env="training",
625+
opts={"title": "Loss", "ylabel": "loss"},
626+
)
627+
628+
vis.learning_curve(
629+
{"train_loss": 0.55, "val_loss": 0.68},
630+
step=4,
631+
win=win,
632+
env="training",
633+
update="append",
634+
)
635+
```
636+
637+
584638
#### vis.stem
585639
This function draws a stem plot. It takes as input an `N` or `NxM` tensor
586640
`X` that specifies the values of the `N` points in the `M` time series.

cypress/integration/UploadDashboard.js

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,37 +25,4 @@ describe('Visdom - Upload Dashboard JSON Feature', () => {
2525
expect(str.toLowerCase()).to.contain('json');
2626
});
2727
});
28-
29-
it('should successfully upload valid dashboard JSON and set it as current environment', () => {
30-
cy.intercept('POST', '/upload_env', (req) => {
31-
req.continue((res) => {
32-
res.delay = 1000;
33-
});
34-
}).as('uploadRequest');
35-
36-
cy.window().then((win) => {
37-
cy.stub(win, 'alert').as('alertStub');
38-
});
39-
40-
cy.fixture('test.json').then((fileContent) => {
41-
cy.get('input[type="file"]').selectFile(
42-
{
43-
contents: fileContent,
44-
fileName: 'test.json',
45-
mimeType: 'application/json',
46-
},
47-
{ force: true }
48-
);
49-
50-
cy.get('button .glyphicon-upload').parent('button').click();
51-
cy.wait('@uploadRequest');
52-
cy.get('@alertStub', { timeout: 15000 }).should('have.been.called');
53-
54-
cy.window().then((win) => {
55-
const envIDs = JSON.parse(win.localStorage.getItem('envIDs'));
56-
expect(envIDs).to.be.an('array');
57-
expect(envIDs[0]).to.match(/^uploaded_/);
58-
});
59-
});
60-
});
6128
});

cypress/integration/export.js

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -65,27 +65,4 @@ describe('Test Export Env as HTML', () => {
6565
expect(html).to.include(env);
6666
});
6767
});
68-
69-
it('Shows alert when all panes are closed before export', () => {
70-
const env = 'export_empty_' + Cypress._.random(0, 1e6);
71-
cy.run('text_basic', { env });
72-
cy.get('.layout .window').should('have.length', 1);
73-
74-
cy.get('.layout .react-grid-item')
75-
.first()
76-
.find('button[title="close"]')
77-
.click();
78-
cy.get('.layout .react-grid-item').should('have.length', 0);
79-
80-
cy.window().then((win) => {
81-
cy.stub(win, 'alert').as('alertStub');
82-
});
83-
84-
cy.get(exportButton).should('not.be.disabled').click();
85-
86-
cy.get('@alertStub').should(
87-
'have.been.calledWith',
88-
'No panes available to export.'
89-
);
90-
});
9168
});

cypress/integration/pane.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const basic_examples = [
1717
// ["Mesh Plot", "plot_special_mesh"], // disabled due to webgl
1818
['Graph Plot', 'plot_special_graph'],
1919
['Sankey Plot', 'plot_special_sankey'],
20+
['Learning Curve', 'plot_line_learning_curve'],
2021
['Matplotlib Plot', 'misc_plot_matplot'],
2122
['Latex Plot', 'misc_plot_latex'],
2223
['Video Pane', 'misc_video_tensor'],

example/components/image.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ def image_basic(viz, env, args):
3939
return img_callback_win
4040

4141

42+
def image_heatmap_basic(viz, env, args):
43+
img = np.random.rand(3, 128, 128)
44+
ys, xs = np.mgrid[0:128, 0:128]
45+
heatmap = np.exp(-((ys - 64) ** 2 + (xs - 64) ** 2) / (2 * 20.0**2))
46+
return viz.image_heatmap(
47+
img,
48+
heatmap,
49+
opts={"title": "Heatmap overlay", "colormap": "jet", "alpha": 0.6},
50+
env=env,
51+
)
52+
53+
4254
def image_callback(viz, env, args):
4355
img_callback_win = image_basic(viz, env, args)
4456
img_coord_text = viz.text("Coords: ", env=env)

example/components/plot_line.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,33 @@ def plot_line_many_updates(viz, env, args):
114114
)
115115

116116

117+
def plot_line_learning_curve(viz, env, args):
118+
epochs = np.arange(1, 11)
119+
train_loss = np.exp(-epochs / 4.0) + 0.08 * np.random.rand(len(epochs))
120+
val_loss = np.exp(-epochs / 3.7) + 0.12 * np.random.rand(len(epochs)) + 0.08
121+
win = viz.learning_curve(
122+
{
123+
"train_loss": train_loss,
124+
"val_loss": val_loss,
125+
},
126+
step=epochs,
127+
opts={"title": "Learning curve", "ylabel": "loss"},
128+
env=env,
129+
)
130+
131+
for epoch in range(11, 16):
132+
viz.learning_curve(
133+
{
134+
"train_loss": np.exp(-epoch / 4.0),
135+
"val_loss": np.exp(-epoch / 3.7) + 0.08,
136+
},
137+
step=epoch,
138+
win=win,
139+
update="append",
140+
env=env,
141+
)
142+
143+
117144
def plot_line_opts(viz, env, args):
118145
return viz.line(
119146
X=np.column_stack(

example/demo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
image_grid,
2828
image_svg,
2929
image_compare_basic,
30+
image_heatmap_basic,
3031
)
3132
from components.plot_scatter import (
3233
plot_scatter_basic,
@@ -73,6 +74,7 @@
7374
plot_line_pytorch,
7475
plot_line_stem,
7576
plot_line_many_updates,
77+
plot_line_learning_curve,
7678
)
7779
from components.plot_special import (
7880
plot_special_boxplot,
@@ -133,6 +135,7 @@ def run_demo(viz, env, args):
133135
image_save_jpeg(viz, env, args)
134136
image_history(viz, env, args)
135137
image_grid(viz, env, args)
138+
image_heatmap_basic(viz, env, args)
136139

137140
# ========== #
138141
# line plots #
@@ -149,6 +152,7 @@ def run_demo(viz, env, args):
149152
plot_line_doubleyaxis(viz, env, args)
150153
plot_line_pytorch(viz, env, args)
151154
plot_line_stem(viz, env, args)
155+
plot_line_learning_curve(viz, env, args)
152156

153157
# ============= #
154158
# scatter plots #

js/main.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {
3636
ROW_HEIGHT,
3737
} from './settings';
3838
import buildExportHtml from './template/exportTemplate';
39+
import ToastContainer from './toasts/ToastContainer';
40+
import { showToast } from './toasts/toastEvents';
3941
import ConnectionIndicator from './topbar/ConnectionIndicator';
4042
import EnvControls from './topbar/EnvControls';
4143
import FilterControls from './topbar/FilterControls';
@@ -876,7 +878,7 @@ const App = () => {
876878
};
877879
const exportCurrentEnvToHtml = () => {
878880
if (!storeData.panes || Object.keys(storeData.panes).length === 0) {
879-
alert('No panes available to export.');
881+
showToast('No panes available to export.', 'error', { duration: 4000 });
880882
return;
881883
}
882884

@@ -1034,6 +1036,7 @@ const App = () => {
10341036

10351037
return (
10361038
<div>
1039+
<ToastContainer />
10371040
{modals}
10381041
<div className="navbar-form navbar-default">
10391042
<span className="navbar-brand visdom-title">visdom</span>

js/toasts/Toast.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Copyright 2017-present, The Visdom Authors
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
*/
9+
10+
import React, { useCallback, useEffect, useRef, useState } from 'react';
11+
12+
const EXIT_ANIMATION_MS = 200;
13+
14+
const Toast = ({
15+
message,
16+
type = 'info',
17+
duration = 4000,
18+
shape = 'rect',
19+
onDismiss,
20+
}) => {
21+
const [isLeaving, setIsLeaving] = useState(false);
22+
const isLeavingRef = useRef(false);
23+
const dismissTimerRef = useRef(null);
24+
const exitTimerRef = useRef(null);
25+
const onDismissRef = useRef(onDismiss);
26+
27+
useEffect(() => {
28+
onDismissRef.current = onDismiss;
29+
}, [onDismiss]);
30+
31+
const startExit = useCallback(() => {
32+
if (isLeavingRef.current) return;
33+
isLeavingRef.current = true;
34+
setIsLeaving(true);
35+
exitTimerRef.current = setTimeout(
36+
() => onDismissRef.current(),
37+
EXIT_ANIMATION_MS
38+
);
39+
}, []);
40+
41+
useEffect(() => {
42+
if (duration > 0) {
43+
dismissTimerRef.current = setTimeout(startExit, duration);
44+
}
45+
return () => {
46+
clearTimeout(dismissTimerRef.current);
47+
clearTimeout(exitTimerRef.current);
48+
};
49+
}, [duration, startExit]);
50+
51+
const isPill = shape === 'pill';
52+
53+
const className = [
54+
'visdom-toast',
55+
`visdom-toast-${type}`,
56+
isPill ? 'visdom-toast-pill' : '',
57+
isLeaving ? 'visdom-toast-leaving' : '',
58+
]
59+
.filter(Boolean)
60+
.join(' ');
61+
62+
return (
63+
<div className={className} role="alert">
64+
<span className="visdom-toast-message">{message}</span>
65+
66+
{!isPill && (
67+
<button
68+
aria-label="Dismiss notification"
69+
className="visdom-toast-close"
70+
onClick={startExit}
71+
type="button"
72+
>
73+
&times;
74+
</button>
75+
)}
76+
77+
{!isPill && duration > 0 && !isLeaving && (
78+
<div
79+
className="visdom-toast-progress"
80+
style={{ animationDuration: `${duration}ms` }}
81+
/>
82+
)}
83+
</div>
84+
);
85+
};
86+
87+
export default Toast;

0 commit comments

Comments
 (0)