Skip to content

Commit 2ffc57a

Browse files
Merge branch 'dev' into react-resizer
2 parents 22c53f7 + 91ed3fa commit 2ffc57a

51 files changed

Lines changed: 3231 additions & 462 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/context/backend.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
- `py/visdom/server/app.py` — Application class, routes, state management.
99
- `py/visdom/server/handlers/web_handlers.py` — HTTP handlers. Copy app attributes in `initialize()`, use `@check_auth`.
1010
- `py/visdom/server/handlers/socket_handlers.py` — WebSocket handlers (read-only + write-enabled).
11-
- `py/visdom/utils/server_utils.py``check_auth`, `broadcast`, `LazyEnvData`, `serialize_env`.
11+
- `py/visdom/utils/server_utils.py``check_auth`, `broadcast`, `LazyEnvData`. Environment persistence lives in `py/visdom/data_model/json_store.py` (`JSONStore`).
1212
- `py/visdom/server/build.py``download_scripts()`: fetches CDN dependencies.
1313

1414
## Coding Rules

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ cypress/screenshots
22
cypress/screenshots_init
33
cypress/downloads
44
cypress/fixtures
5+
playwright/screenshots
6+
playwright/screenshots_init
7+
playwright/screenshots_diff
58
node_modules
69
build
710
th/CMakeLists.txt

README.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,32 @@ VISDOM_USE_ENV_CREDENTIALS=1 visdom -enable_login
227227
You can also use `VISDOM_COOKIE` variable to provide cookies value if the cookie file wasn't generated, or the
228228
flag `-force_new_cookie` was set.
229229

230+
#### HTTPS Support
231+
232+
To run the visdom server over HTTPS,user need to provide an SSL certificate and key file:
233+
234+
```bash
235+
# Generate a self-signed certificate (for development only)
236+
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
237+
238+
# Start the server with HTTPS
239+
python -m visdom.server -ssl_certfile cert.pem -ssl_keyfile key.pem
240+
```
241+
242+
Access the server at `https://localhost:8097`.
243+
244+
Connect via the Python client:
245+
```python
246+
# For Production - real CA-signed certificate (default)
247+
vis = visdom.Visdom(server="https://myserver.com")
248+
249+
# For Development - self signed certificate
250+
vis = visdom.Visdom(server="https://localhost", ssl_verify=False)
251+
```
252+
253+
> **Note**: `ssl_verify=False` disables certificate verification and should only be used in development with self-signed certificates. Do not use in production.
254+
255+
230256
#### Python example
231257
```python
232258
import visdom
@@ -268,6 +294,7 @@ Other options are either currently unused (endpoint, ipv6) or used for internal
268294
Visdom offers the following basic visualization functions:
269295
- [`vis.image`](#visimage) : image
270296
- [`vis.image_heatmap`](#visimageheatmap) : image with heatmap overlay
297+
- [`vis.update_image_slider`](#visupdate_image_slider) : set visible frame of an image_history pane
271298
- [`vis.images`](#visimages) : list of images
272299
- [`vis.text`](#vistext) : arbitrary HTML
273300
- [`vis.properties`](#visproperties) : properties grid
@@ -332,6 +359,98 @@ vis._send({'data': [trace], 'layout': layout, 'win': 'mywin'})
332359
- [`vis.replay_log`](#visreplay_log): replay the actions from the provided log file
333360

334361

362+
## Loggers
363+
364+
Framework-specific logging bridges that wrap the Visdom API so training loops stay focused on training. Each logger lives in its own submodule and handles window creation, step tracking, and throttling internally.
365+
366+
### PyTorch
367+
368+
`visdom.pytorch.VisdomLogger` is a context manager for raw PyTorch training loops. Call `tracker.log(name, value)` for any scalar — no `viz.line()` arguments needed.
369+
370+
**Epoch-level logging** (recommended default — one call per epoch):
371+
372+
```python
373+
import visdom
374+
from visdom.pytorch import VisdomLogger
375+
376+
viz = visdom.Visdom()
377+
378+
with VisdomLogger(viz, env="my_run") as tracker:
379+
for epoch in range(num_epochs):
380+
train_loss = run_train_epoch(model, loader)
381+
val_loss = run_val_epoch(model, val_loader)
382+
383+
tracker.log("Train Loss", train_loss)
384+
tracker.log("Val Loss", val_loss)
385+
tracker.log("LR", optimizer.param_groups[0]["lr"])
386+
```
387+
388+
**Per-batch logging with `log_every`** — use when logging inside the batch loop on large datasets:
389+
390+
```python
391+
with VisdomLogger(viz, env="my_run", log_every=50) as tracker:
392+
for epoch in range(num_epochs):
393+
for inputs, targets in train_loader:
394+
loss = criterion(model(inputs), targets)
395+
optimizer.zero_grad()
396+
loss.backward()
397+
optimizer.step()
398+
399+
# sent to Visdom every 50 batches, not every batch
400+
tracker.log("Train Loss", loss.item(), xlabel="step")
401+
tracker.log("LR", optimizer.param_groups[0]["lr"], xlabel="step")
402+
```
403+
404+
**Parameters:**
405+
- `viz`: a connected `visdom.Visdom()` instance
406+
- `env`: environment name (default: auto-generated from timestamp)
407+
- `log_every`: send every N calls per metric — use with per-batch logging on large datasets (default: `1`)
408+
409+
Each unique name passed to `tracker.log()` gets its own window. The first call creates it; subsequent calls append. See `example/train_example.py` for a full working example.
410+
411+
### scikit-learn
412+
413+
`visdom.loggers.VisdomSklearnLogger` patches all sklearn `fit()` calls so every estimator trained after `autolog()` logs to Visdom automatically — no per-estimator code needed.
414+
415+
**Plain estimators** (classifiers, regressors, clusterers) produce a text pane with the estimator name, dataset shape, training score, fit time, and all hyperparameters.
416+
417+
**GridSearchCV / RandomizedSearchCV** produce a bar chart of `mean_test_score` per parameter combination and a text pane with `best_score_`, `best_params_`, and fit time.
418+
419+
```python
420+
import visdom
421+
from visdom.loggers import VisdomSklearnLogger
422+
from sklearn.ensemble import RandomForestClassifier
423+
from sklearn.linear_model import Ridge
424+
from sklearn.model_selection import GridSearchCV
425+
426+
viz = visdom.Visdom()
427+
VisdomSklearnLogger.autolog()
428+
429+
clf = RandomForestClassifier(n_estimators=100)
430+
clf.fit(X_train, y_train) # -> text pane
431+
432+
reg = Ridge(alpha=1.0)
433+
reg.fit(X_train, y_train) # -> text pane
434+
435+
gs = GridSearchCV(clf, param_grid, cv=3)
436+
gs.fit(X_train, y_train) # -> bar chart + text pane
437+
```
438+
439+
If you have a custom Visdom connection (non-default port, remote server, auth), pass it explicitly:
440+
441+
```python
442+
import visdom
443+
444+
viz = visdom.Visdom(port=8098, server="http://myserver")
445+
VisdomSklearnLogger.autolog(viz, env="sklearn_run")
446+
```
447+
448+
**Parameters:**
449+
- `viz`: a connected `visdom.Visdom()` instance (optional — created internally if not passed)
450+
- `env`: environment name (default: `viz.env` if set, otherwise auto-generated from timestamp)
451+
452+
See `example/train_sklearn_example.py` for a full working example covering plain estimators and grid search.
453+
335454
## Details
336455
<img src="https://user-images.githubusercontent.com/19650074/198747904-7a8a580f-851a-45fb-8f45-94e54a910ee2.png"/>
337456

@@ -356,6 +475,18 @@ The following `opts` are supported:
356475
- `caption`: Caption for the image
357476
- `store_history`: Keep all images stored to the same window and attach a slider to the bottom that will let you select the image to view. You must always provide this opt when sending new images to an image with history.
358477

478+
#### vis.update_image_slider
479+
480+
Programmatically set the visible frame of an `image_history` pane from Python:
481+
482+
```python
483+
win = vis.image(img, opts=dict(store_history=True))
484+
vis.image(img2, win=win, opts=dict(store_history=True))
485+
vis.update_image_slider(win, index=1) # show second frame
486+
```
487+
488+
The `index` is 0-based and is clamped to the valid range by the server. NumPy integer scalars (e.g. `np.int64`) are accepted and coerced automatically. Passing a non-integer or a non-`image_history` window raises an error.
489+
359490
> **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.
360491
361492

@@ -499,6 +630,7 @@ The function accepts the following arguments:
499630
- `labels`: a list of corresponding labels for the tensors provided for `features`
500631
- `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.
501632
- `data_type=str`: (optional) currently the only acceptable value here is `"html"`
633+
- `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.
502634

503635
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.
504636

@@ -909,6 +1041,7 @@ The following `opts` are generic in the sense that they are the same for all vis
9091041
- `opts.marginright` : right margin (in pixels)
9101042
- `opts.margintop` : top margin (in pixels)
9111043
- `opts.marginbottom`: bottom margin (in pixels)
1044+
- `opts.caption` : caption displayed below the plot (`string`; optional)
9121045

9131046
`opts` are passed as dictionary in python scripts.You can pass `opts` like:
9141047

REFACTORING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ This is the primary TODO from PR #675: *"move the logic that actually parses env
7979
| File | Purpose |
8080
|------|---------|
8181
| `environment.py` | `Environment` class wrapping `{"jsons": {}, "reload": {}}` dict. Methods: `get_window()`, `set_window()`, `remove_window()`, `list_windows()`, `get_reload()` |
82-
| `state_manager.py` | `StateManager` class wrapping the top-level `state` dict. Methods: `get_env()`, `create_env()`, `delete_env()`, `list_envs()`, `fork_env()`, `serialize()`, `serialize_all()`. Absorbs functions from `server_utils.py` (`serialize_env`, `serialize_all`, `load_env`, `gather_envs`, `compare_envs`) |
82+
| `state_manager.py` | `StateManager` class wrapping the top-level `state` dict. Methods: `get_env()`, `create_env()`, `delete_env()`, `list_envs()`, `fork_env()`, `serialize()`, `serialize_all()`. Absorbs functions from `server_utils.py` (`load_env`, `gather_envs`, `compare_envs`). Environment file serialization now lives in `data_model/json_store.py` (`JSONStore`) via the `DataStore` abstraction |
8383
| `window.py` | Typed window structures (dataclasses/TypedDicts) replacing raw dicts built in `server_utils.py:window()` (lines 202-258) |
8484

8585
### 3b. Refactor handlers to use data model
@@ -109,12 +109,12 @@ py/visdom/server/
109109
**Effort: Medium-Large | Risk: Medium | PRs: 2 | Dependencies: Phase 3**
110110

111111
### 4a. Wrap blocking file I/O with `run_in_executor`
112-
Currently only **ONE** place uses async I/O (`socket_handlers.py:123` for `serialize_all`). All others block the Tornado event loop:
112+
Currently only **ONE** place uses async I/O (`socket_handlers.py:123` for `storage.save_all`). All others block the Tornado event loop:
113113

114114
| File | Lines | Blocking Operation |
115115
|------|-------|-------------------|
116116
| `server_utils.py` | 72 | Cookie file write |
117-
| `server_utils.py` | 121-155 | `serialize_env()` — JSON file write |
117+
| `data_model/json_store.py` | `save_envs()` | `JSONStore` — JSON file write |
118118
| `server_utils.py` | 273-303 | `compare_envs()` — env file read |
119119
| `app.py` | 141 | Layout save (file write) |
120120
| `app.py` | 155 | Layout load (file read) |

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
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
before(() => {
11+
cy.visit('/');
12+
});
13+
14+
describe('Parallel Coordinates Pane', () => {
15+
it('parallel_coordinates_basic', () => {
16+
cy.run('plot_special_parallel_coordinates');
17+
18+
cy.get('.layout .window').should('have.length', 1);
19+
20+
cy.get('.content').contains('Experiment Comparison');
21+
});
22+
23+
it('parallel_coordinates_close', () => {
24+
cy.run('plot_special_parallel_coordinates');
25+
cy.get('.layout .react-grid-item')
26+
.first()
27+
.find('button[title="close"]')
28+
.click();
29+
cy.get('.layout .window').should('have.length', 0);
30+
});
31+
});

example/components/image.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,46 @@ def image_grid(viz, env, args):
133133
env=env,
134134
)
135135

136+
# image slider sync demo — one slider drives two panes
137+
def image_slider_sync(viz, env, args):
138+
n_frames = 4
139+
colors = np.linspace(0.2, 0.8, n_frames)
140+
141+
win_a = None
142+
win_b = None
143+
for i, c in enumerate(colors):
144+
frame = np.full((3, 128, 256), c)
145+
win_a = viz.image(
146+
frame,
147+
win=win_a,
148+
opts=dict(
149+
title="Model A",
150+
caption="Frame {}".format(i),
151+
store_history=True,
152+
),
153+
env=env,
154+
)
155+
win_b = viz.image(
156+
1.0 - frame,
157+
win=win_b,
158+
opts=dict(
159+
title="Model B (synced)",
160+
caption="Frame {}".format(i),
161+
store_history=True,
162+
),
163+
env=env,
164+
)
165+
166+
viz.update_image_slider(win_a, 0, env=env)
167+
viz.update_image_slider(win_b, 0, env=env)
168+
169+
def on_slider_moved(event):
170+
if event["event_type"] != "SliderMoved":
171+
return
172+
viz.update_image_slider(win_b, event["index"], env=env)
173+
174+
viz.register_event_handler(on_slider_moved, win_a)
175+
136176

137177
# SVG plotting
138178
def image_svg(viz, env, args):

0 commit comments

Comments
 (0)