diff --git a/doc/users/next_whats_new/contour_boolean_levels.rst b/doc/users/next_whats_new/contour_boolean_levels.rst new file mode 100644 index 000000000000..27c64980ed44 --- /dev/null +++ b/doc/users/next_whats_new/contour_boolean_levels.rst @@ -0,0 +1,9 @@ +Boolean contour defaults +------------------------ + +`.Axes.contour` and `.Axes.contourf` now recognise boolean input arrays when +``levels`` is omitted. Line contours default to a single threshold at ``0.5`` +while filled contours draw the three regions ``[0.0, 0.5, 1.0]`` to +distinguish ``False`` and ``True`` values. Passing explicit ``levels``, using a +custom locator, or enabling logarithmic scaling continues to behave exactly as +before. diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 144eadeae2c6..60eed7c4185b 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -1123,15 +1123,24 @@ def _process_contour_level_args(self, args): """ if self.levels is None: if len(args) == 0: - levels_arg = 7 # Default, hard-wired. + levels_arg = None else: levels_arg = args[0] else: levels_arg = self.levels - if isinstance(levels_arg, Integral): - self.levels = self._autolev(levels_arg) + if (levels_arg is None and getattr(self, "_is_bool_z", False) + and self.locator is None and not self.logscale): + if self.filled: + self.levels = np.array([0.0, 0.5, 1.0], dtype=np.float64) + else: + self.levels = np.array([0.5], dtype=np.float64) else: - self.levels = np.asarray(levels_arg, np.float64) + if levels_arg is None: + levels_arg = 7 # Default, hard-wired. + if isinstance(levels_arg, Integral): + self.levels = self._autolev(levels_arg) + else: + self.levels = np.asarray(levels_arg, np.float64) if not self.filled: inside = (self.levels > self.zmin) & (self.levels < self.zmax) @@ -1446,8 +1455,11 @@ def _contour_args(self, args, kwargs): else: fn = 'contour' nargs = len(args) + self._is_bool_z = False if nargs <= 2: - z = ma.asarray(args[0], dtype=np.float64) + z_input = args[0] + self._is_bool_z = ma.getdata(ma.asarray(z_input)).dtype.kind == "b" + z = ma.asarray(z_input, dtype=np.float64) x, y = self._initialize_x_y(z) args = args[1:] elif nargs <= 4: @@ -1475,7 +1487,9 @@ def _check_xyz(self, args, kwargs): x = np.asarray(x, dtype=np.float64) y = np.asarray(y, dtype=np.float64) - z = ma.asarray(args[2], dtype=np.float64) + z_input = args[2] + self._is_bool_z = ma.getdata(ma.asarray(z_input)).dtype.kind == "b" + z = ma.asarray(z_input, dtype=np.float64) if z.ndim != 2: raise TypeError(f"Input z must be 2D, not {z.ndim}D") @@ -1585,6 +1599,14 @@ def _initialize_x_y(self, z): If array-like, draw contour lines at the specified levels. The values must be in increasing order. + .. note:: + + If *Z* has a boolean dtype and *levels* is omitted (or ``None``), + `.contour` adds a single level at ``0.5`` and `.contourf` draws + filled regions using the levels ``[0.0, 0.5, 1.0]``. Supplying + explicit *levels*, configuring *locator*, or using a logarithmic + scale preserves the provided behaviour. + Returns ------- `~.contour.QuadContourSet` diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index e42206b8cb79..93a7e8158148 100644 --- a/lib/matplotlib/tests/test_contour.py +++ b/lib/matplotlib/tests/test_contour.py @@ -84,6 +84,57 @@ def test_contour_Nlevels(): assert (cs1.levels == cs2.levels).all() +def test_contour_bool_default_levels(): + z = np.array([[True, False], [False, True]]) + + fig, ax = plt.subplots() + cs = ax.contour(z) + assert np.allclose(cs.levels, [0.5]) + + +def test_contourf_bool_default_levels(): + z = np.array([[True, False], [False, True]]) + + fig, ax = plt.subplots() + cs = ax.contourf(z) + assert np.allclose(cs.levels, [0.0, 0.5, 1.0]) + + +def test_contour_bool_explicit_levels_preserved(): + z = np.array([[True, False], [False, True]]) + + fig, ax = plt.subplots() + levels = [0.25, 0.75] + cs = ax.contour(z, levels=levels) + assert np.allclose(cs.levels, levels) + + +def test_contourf_masked_bool_default_levels(): + data = np.array([[True, False], [False, True]]) + mask = [[False, True], [False, False]] + z = np.ma.array(data, mask=mask) + + fig, ax = plt.subplots() + cs = ax.contourf(z) + assert np.allclose(cs.levels, [0.0, 0.5, 1.0]) + + +def test_contour_int_data_no_bool_special_case(): + z = np.array([[1, 0], [0, 1]], dtype=int) + + fig, ax = plt.subplots() + cs = ax.contour(z) + assert not np.allclose(cs.levels, [0.5]) + + +def test_contourf_bool_lognorm_no_override(): + z = np.ones((2, 2), dtype=bool) + + fig, ax = plt.subplots() + cs = ax.contourf(z, norm=LogNorm()) + assert not np.allclose(cs.levels, [0.0, 0.5, 1.0]) + + def test_contour_badlevel_fmt(): # Test edge case from https://github.com/matplotlib/matplotlib/issues/9742 # User supplied fmt for each level as a dictionary, but Matplotlib changed