matplotlib / matplotlib/matplotlib

[Bug]: a scalar-only custom scale silently blanks all 3D data instead of drawing it

Aperta
#32,129 1 commento 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

topic: transforms and scales
Lingua principale
Python
Stelle
23.2k
Fork
8.5k
Merge medio
1g 6h
PR unite (30g)
66

Descrizione

### Bug summary

A third-party scale whose `limit_range_for_scale` is written for scalars, which is how Matplotlib's own `LogScale` writes it, makes every 3D artist on that axis silently vanish. Ordinary finite data, no warning, no exception.

`ScaleBase.val_in_range` calls `limit_range_for_scale(arr, arr, ...)` with an array. A scalar-oriented implementation raises `ValueError: truth value of an array is ambiguous`, and the fallback treats that as **nothing is in range**:

```python
try:
vmin, vmax = self.limit_range_for_scale(arr, arr, minpos=1e-300)
except (TypeError, ValueError):
result = np.zeros(arr.shape, dtype=bool)
```

`_scale_invalid_mask` then negates it, so every point is marked invalid and replaced with NaN.

The scalar form is not an unusual thing to write. It is what `LogScale.limit_range_for_scale` does:

```python
return (minpos if vmin <= 0 else vmin,
minpos if vmax <= 0 else vmax)
```

`LogScale` is unaffected only because it overrides `val_in_range`. A third-party scale written before 3.11 cannot have overridden a method that did not exist yet.

### Code for reproduction

```python
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.scale import ScaleBase, register_scale
from matplotlib.transforms import IdentityTransform
from matplotlib.ticker import AutoLocator, ScalarFormatter

class ScalarOnlyScale(ScaleBase):
name = "scalaronly"

def get_transform(self):
return IdentityTransform()

def set_default_locators_and_formatters(self, axis):
axis.set_major_locator(AutoLocator())
axis.set_major_formatter(ScalarFormatter())

def limit_range_for_scale(self, vmin, vmax, minpos):
# The same shape as LogScale.limit_range_for_scale.
return (minpos if vmin <= 0 else vmin,
minpos if vmax <= 0 else vmax)

register_scale(ScalarOnlyScale)

xs = np.array([0., 1., 2., 3.])
ys = np.array([0., 1., 2., 3.])
zs = np.array([1., 2., 3., 4.]) # all finite, all positive

fig = plt.figure()
ax = fig.add_subplot(projection="3d")
line, = ax.plot(xs, ys, zs)
ax.scatter(xs, ys, zs)
ax.set_zscale("scalaronly")
fig.canvas.draw()

print(np.asarray(line.get_data())) # all nan
fig.savefig("blank.png")
```

### Actual outcome

The line and the points are gone. Nothing is raised.

```
[[nan nan nan nan]
[nan nan nan nan]]
```

Measured on the same figure with and without `set_zscale("scalaronly")`:

| | finite points after projection | PNG size |
|---|---|---|
| linear scale | line 4/4, scatter 4/4 | 18315 bytes |
| `scalaronly` | line 0/4, scatter 0/4 | 7414 bytes |

Directly:

```python
>>> s = ScalarOnlyScale(None)
>>> s.val_in_range(5.0)
True
>>> s.val_in_range(np.array([1., 2., 3.]))
array([False, False, False])
```

The scalar answer is right and the array answer is the opposite of right.

### Expected outcome

The data is drawn. Every value is finite and inside the scale's domain, and the scalar path agrees.

2D plots on the same scale are unaffected, because `_scale_invalid_mask` is only used by `mpl_toolkits.mplot3d`.

### Additional information

`val_in_range` arrived in 3.11 (#31306) and `_scale_invalid_mask` began calling it over whole arrays in #31737. Before that, a scale only needed `limit_range_for_scale` to work on scalars, which is what the base class had always asked for.

The failure mode is what makes this worth reporting rather than the incompatibility itself. Falling back to "no value is in range" turns an unsupported call signature into deleted data. The safer direction is to fall back to the scalar semantics the implementation does support:

```python
except (TypeError, ValueError):
result = np.array(
[self.val_in_range(v) for v in np.atleast_1d(arr).ravel()]
).reshape(arr.shape)
```

which returns `[True, True, True]` for the case above. Assuming everything is valid when the domain cannot be determined would also be safer than the current default, since an unknown domain is not evidence of invalid data.

Two smaller things noticed alongside:

- `scale.pyi` still declares `def val_in_range(self, val: float) -> bool`, while the implementation documents and returns an array for array input.
- Related but separate, so not filed here: `Line3D.set_data_3d` accepts two or four coordinate sequences because `zip('xyz', args)` truncates silently, and the failure then surfaces at draw time as `TypeError: _scale_invalid_mask() missing 1 required positional argument`, which names an internal function.

I have not opened a PR for this one, since changing the fallback is a decision about `Scale` compatibility rather than a mechanical fix, and I would rather have your view on the direction first. Happy to write it either way.

Found while investigating #32127, which comes from the same change chain but is a different failure.

### Operating system

Linux (Debian 13)

### Matplotlib Version

3.11.1

### Matplotlib Backend

Agg

### Python version

3.14

### Jupyter version

N/A

### Installation

pip

Guida per i contributori

Apri la guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Direzione di ricerca

Inizia leggendo ScaleBase.val_in_range e il percorso delle chiamate di mplot3d _scale_invalid_mask; scale.pyi documenta inoltre la firma attuale limitata agli scalari. Riproduci l’esempio ScalarOnlyScale, quindi aggiungi una copertura di regressione che dimostri che i dati finiti di linee e scatter 3D rimangono visibili, preservando al contempo il comportamento scalare. Il lavoro è completato quando le chiamate con array non supportati non contrassegnano più silenziosamente ogni valore come non valido.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Valutazione

Stack tecnologico
python
Ambito
data-visualization
Tipo di issue
Bug
Difficoltà
3/5
Tempo stimato
1-2 giorni
Stato di attività
Tranquilla
Chiarezza
Abbastanza chiara
Idoneità per principianti
62/100

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.