Skip to content

Some short lines added with plot() are missing in the PNG output depending on DPI #20192

Open

Description

@tskir

Bug report

Depending on DPI, a figure saved as PNG is missing some short lines.

Code for reproduction

import matplotlib
from matplotlib import pyplot as plt
import numpy as np

matplotlib.use('agg')

# Generate a set of 50 random points.
np.random.seed(0)
all_x, all_y = (np.random.rand(50) for _ in range(2))

# Scatter dots in blue.
plt.scatter(all_x, all_y, c='blue', s=2)

# Plot very short lines (length 0.001) in red with a small offset on Y.
for x, y in zip(all_x, all_y):
    plt.plot((x, x + 0.001), (y + 0.01, y + 0.01), c='red', linewidth=2)

# Save with different resolutions
plt.savefig('bug_96.png', dpi=96)
plt.savefig('bug_300.png', dpi=300)

Actual outcome

Depending on export DPI, some short segments (in red, added by plot()) are missing, while all dots (in blue, added by scatter() are always present).

At 96 DPI:
image

At 300 DPI:
image

Expected outcome

The segments should be always present, regardless of the resolution.

Matplotlib version

  • Operating system: Ubuntu 20.04.2 LTS
  • Matplotlib version (import matplotlib; print(matplotlib.__version__)): 3.4.2
  • Matplotlib backend (print(matplotlib.get_backend())): agg
  • Python version: 3.8.5

Python is installed via apt-get, Matplotlib installed system-wide using PIP.

Activity

  1. tskir commented on May 9, 2021

    @tskir
    Author

    Interestingly enough, the bug disappears with matplotlib.use('cairo').

    At 96 DPI with cairo:
    image

    At 300 DPI with cairo:
    image

  2. jklymak commented on May 9, 2021

    @jklymak
    Member

    If you turn snapping off this is fixed. plot(....snap=False)

  3. jklymak commented on May 9, 2021

    @jklymak
    Member

    FWIW, I think snapping for ticks makes some sense. I don't think there are many situations where having snapping on for other artists is a good idea at all. I'm not familiar enough with matplotlib's history wrt snapping, but it seems a bad idea in general.

    I also recommend strongly getting around any bad ideas matplotlib may have had by saving at relatively high dpi and then using a graphics package to reduce the size optically. In my opinion we try to do too many "smart" things with low resolution images that really should be done in post-processing, not while we compose the images.

  4. tskir commented on May 9, 2021

    @tskir
    Author

    @jklymak Indeed, thank you very much for the pointer and for such a quick reply!

    I'll apply this fix, and will leave this issue up for any related discussions.

  5. github-actions commented on Oct 20, 2025

    @github-actions

    This issue has been marked "inactive" because it has been 365 days since the last comment. If this issue is still present in recent Matplotlib releases, or the feature request is still wanted, please leave a comment and this label will be removed. If there are no updates in another 30 days, this issue will be automatically closed, but you are free to re-open or create a new issue if needed. We value issue reports, and this procedure is meant to help us resurface and prioritize issues that have not been addressed yet, not make them disappear. Thanks for your help!

  6. tacaswell commented on Oct 20, 2025

    @tacaswell
    Member

    I have somewhat mixed thoughts on this. On one hand, snapping things out of existence is bad, but on the other what is getting rendered in this case is relatively miss-leading

    The snapping code only kicks in on paths if the lines are only horizontal or vertical which this case does hit. This is to make sure a bunch of long horizontal lines all look the same weight. Think of a 2 pixel wide line, if the path goes down the boundary between two pixels you get a 2 pixel wide 100% color rendering. If the path goes down the center of a pixel, then you get a 3 pixel wide line with the center row being 100% and the outer pixels being 50% (due to anti-aliasing) or a 3 pixel wide line (if you turn off AA). These defaults were decided back before high-dpi screens existed so (what we now call) low-dpi was pretty important.

    On the other hand, what is being rendered for the lines is mostly the caps not the actual line (setting solid_capstyle='butt' shows this), however in that case we are still seeing the same issue.

    I think the right fix is to add logic in

    static bool should_snap(VertexSource &path, e_snap_mode snap_mode, unsigned total_vertices)
    {
    // If this contains only straight horizontal or vertical lines, it should be
    // snapped to the nearest pixels
    double x0 = 0, y0 = 0, x1 = 0, y1 = 0;
    unsigned code;
    switch (snap_mode) {
    case SNAP_AUTO:
    if (total_vertices > 1024) {
    return false;
    }
    code = path.vertex(&x0, &y0);
    if (code == agg::path_cmd_stop) {
    return false;
    }
    while ((code = path.vertex(&x1, &y1)) != agg::path_cmd_stop) {
    switch (code) {
    case agg::path_cmd_curve3:
    case agg::path_cmd_curve4:
    return false;
    case agg::path_cmd_line_to:
    if (fabs(x0 - x1) >= 1e-4 && fabs(y0 - y1) >= 1e-4) {
    return false;
    }
    }
    x0 = x1;
    y0 = y1;
    }
    return true;
    case SNAP_FALSE:
    return false;
    case SNAP_TRUE:
    return true;
    }
    return false;
    }
    that turns off snapping for path segments that are 2 points long and are less than 2 pixel apart.

    This patch seems to fix the problem, but have not tested what other consequences this has.

    diff --git a/src/path_converters.h b/src/path_converters.h
    index 1482aeed95..e8275974fa 100644
    --- a/src/path_converters.h
    +++ b/src/path_converters.h
    @@ -561,8 +561,9 @@ class PathSnapper
             // If this contains only straight horizontal or vertical lines, it should be
             // snapped to the nearest pixels
             double x0 = 0, y0 = 0, x1 = 0, y1 = 0;
    +        double xstart, ystart;
             unsigned code;
    -
    +        int lineto_count = 0;
             switch (snap_mode) {
             case SNAP_AUTO:
                 if (total_vertices > 1024) {
    @@ -570,6 +571,8 @@ class PathSnapper
                 }
     
                 code = path.vertex(&x0, &y0);
    +            xstart = x0;
    +            ystart = y0;
                 if (code == agg::path_cmd_stop) {
                     return false;
                 }
    @@ -580,14 +583,20 @@ class PathSnapper
                     case agg::path_cmd_curve4:
                         return false;
                     case agg::path_cmd_line_to:
    +                    lineto_count++;
                         if (fabs(x0 - x1) >= 1e-4 && fabs(y0 - y1) >= 1e-4) {
                             return false;
                         }
                     }
                     x0 = x1;
                     y0 = y1;
    -            }
     
    +            }
    +            // we only have two verticies and the displacement in either direction
    +            // is less than two pixels
    +            if (lineto_count == 1 && (fabs(xstart - x0) < 2 && fabs(ystart - y0) < 2)) {
    +                return false;
    +            }
                 return true;
             case SNAP_FALSE:
                 return false;
    
  7. added this to the v3.12.0 milestone on Oct 20, 2025
  8. added
    keepItems to be ignored by the “Stale” Github Action
    and removed
    status: inactiveMarked by the “Stale” Github Action
    on Oct 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions