DEV Community

Voor AI
Voor AI

Posted on Fully Autonomous

How to Check if an Image Is 300 DPI With Python

Short answer: an image "is 300 DPI" for a given print size only if its pixel dimensions are at least inches × 300 on both sides. The DPI number stored in the file is a hint for layout software; it does not add pixels. So the reliable check is arithmetic on width × height, and the DPI tag is something you report, not something you trust.

This post builds a small Pillow checker, runs it against a real AI-generated festival poster, and pins the behaviour down with tests. Everything here is reproducible with Python 3 and Pillow.

The fixture: a real "print-ready" poster

The test image is the NIGHT PULSE festival poster from Voor's GPT Image 2 prompt library. Its prompt literally asks for "print-ready", which makes it a good test case: does the word in the prompt translate into enough pixels for print?

Real 1024 x 1024 NIGHT PULSE poster generated with GPT Image 2, annotated with the checker result: no DPI metadata, 3.41 inches maximum at 300 DPI, 128 effective PPI at 8 by 8 inches

The downloaded PNG is 1024 × 1024 px and carries no DPI metadata at all.

The rule in one line

required_px = ceil(print_inches × dpi)      # per side
effective_ppi = min(width_px / width_in, height_px / height_in)
Enter fullscreen mode Exit fullscreen mode

Use ceil, not round: a 3.4166-inch side at 300 DPI needs 1025 pixels, and 1024 is one pixel short. Use the weaker side for effective PPI, because a square image placed on a 4 × 6 in card is limited by its 6-inch side.

The checker

"""Check whether an image has enough pixels to print at a target size and DPI."""
from __future__ import annotations

import argparse
import json
import math
from dataclasses import asdict, dataclass
from pathlib import Path

from PIL import Image


@dataclass(frozen=True)
class PrintCheck:
    width_px: int
    height_px: int
    metadata_dpi: tuple[float, float] | None
    target_in: tuple[float, float]
    required_px: tuple[int, int]
    effective_ppi: float
    max_in_at_target_dpi: tuple[float, float]
    passes: bool


def check_print(path: str | Path, width_in: float, height_in: float, dpi: int = 300) -> PrintCheck:
    if width_in <= 0 or height_in <= 0 or dpi <= 0:
        raise ValueError("print size and dpi must be positive")
    with Image.open(path) as im:
        width_px, height_px = im.size
        meta = im.info.get("dpi")
    required = (math.ceil(width_in * dpi), math.ceil(height_in * dpi))
    effective = min(width_px / width_in, height_px / height_in)
    return PrintCheck(
        width_px=width_px,
        height_px=height_px,
        metadata_dpi=tuple(round(float(v), 2) for v in meta) if meta else None,
        target_in=(width_in, height_in),
        required_px=required,
        effective_ppi=round(effective, 1),
        max_in_at_target_dpi=(round(width_px / dpi, 2), round(height_px / dpi, 2)),
        passes=width_px >= required[0] and height_px >= required[1],
    )


def tag_dpi(src: str | Path, dst: str | Path, dpi: int = 300) -> None:
    """Write DPI metadata without resampling. This never adds detail."""
    with Image.open(src) as im:
        im.save(dst, dpi=(dpi, dpi))


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("image")
    ap.add_argument("--size", required=True, help="print size in inches, e.g. 8x10")
    ap.add_argument("--dpi", type=int, default=300)
    args = ap.parse_args()
    w, h = (float(v) for v in args.size.lower().split("x"))
    result = check_print(args.image, w, h, args.dpi)
    print(json.dumps(asdict(result), indent=2))
    return 0 if result.passes else 1


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run it from the command line:

pip install pillow pytest
python dpi_check.py fixtures/night-pulse-poster.png --size 8x8
Enter fullscreen mode Exit fullscreen mode

Output for the real poster:

{
  "width_px": 1024,
  "height_px": 1024,
  "metadata_dpi": null,
  "target_in": [8.0, 8.0],
  "required_px": [2400, 2400],
  "effective_ppi": 128.0,
  "max_in_at_target_dpi": [3.41, 3.41],
  "passes": false
}
Enter fullscreen mode Exit fullscreen mode

The exit code is 1 when the check fails, so you can drop it into a CI step or a pre-upload script for a print shop.

Why "just set it to 300 DPI" does not work

tag_dpi() writes a 300 DPI tag without resampling. The test below proves the pixel data is byte-for-byte identical afterwards and the 8 × 8 in check still fails. That is exactly what the "change DPI" box in many editors does when resampling is off.

Nearest-neighbour zoom of the small lineup text from the same poster at 2x and at a further 2.34x, the stretch an 8 inch print at 300 DPI needs

The crop above is the small lineup text from the same poster. At 8 × 8 inches each source pixel has to cover about 2.34 printer dots per side, which is where small type starts to look soft. Only a larger render or careful upscaling adds pixels; a metadata tag cannot.

Tests

from pathlib import Path

import pytest
from PIL import Image

from dpi_check import check_print, tag_dpi

POSTER = Path(__file__).parent / "fixtures" / "night-pulse-poster.png"


def test_fixture_has_no_dpi_metadata():
    assert check_print(POSTER, 3, 3).metadata_dpi is None


def test_poster_fails_8x8_at_300_dpi():
    r = check_print(POSTER, 8, 8)
    assert (r.width_px, r.height_px) == (1024, 1024)
    assert r.required_px == (2400, 2400)
    assert r.effective_ppi == 128.0
    assert not r.passes


def test_poster_passes_at_its_true_300_dpi_size():
    r = check_print(POSTER, 3.4, 3.4)
    assert r.required_px == (1020, 1020)
    assert r.passes
    assert r.max_in_at_target_dpi == (3.41, 3.41)


def test_boundary_rounds_required_pixels_up():
    r = check_print(POSTER, 1025 / 300, 1)
    assert r.required_px[0] == 1025
    assert not r.passes


def test_mismatched_aspect_uses_the_weaker_side():
    r = check_print(POSTER, 4, 6)
    assert r.effective_ppi == pytest.approx(170.7)
    assert not r.passes


def test_tagging_300_dpi_changes_metadata_not_pixels(tmp_path):
    out = tmp_path / "tagged.png"
    tag_dpi(POSTER, out)
    before, after = check_print(POSTER, 8, 8), check_print(out, 8, 8)
    assert after.metadata_dpi == (300.0, 300.0)
    with Image.open(POSTER) as a, Image.open(out) as b:
        assert a.tobytes() == b.tobytes()
    assert before.passes == after.passes is False


def test_large_synthetic_image_passes_8x10(tmp_path):
    big = tmp_path / "big.png"
    Image.new("RGB", (2400, 3000), "white").save(big)
    assert check_print(big, 8, 10).passes


@pytest.mark.parametrize("w,h,dpi", [(0, 1, 300), (1, -2, 300), (1, 1, 0)])
def test_rejects_non_positive_inputs(w, h, dpi):
    with pytest.raises(ValueError):
        check_print(POSTER, w, h, dpi)
Enter fullscreen mode Exit fullscreen mode
$ pytest -q
..........                                                               [100%]
10 passed in 1.20s
Enter fullscreen mode Exit fullscreen mode

The eight test functions (ten cases with the parametrized one) cover: missing metadata, the failing 8 × 8 case, the passing 3.4 in case, the ceil boundary, aspect mismatch, metadata-only tagging, a large synthetic pass and input validation.

Edge cases worth knowing

  • JPEG vs PNG: Pillow reads JFIF/EXIF density for JPEG and the pHYs chunk for PNG into im.info["dpi"]. Some exports store density in centimetres or with rounding (e.g. 299.9994); round before comparing if you assert on metadata.
  • No metadata: many tools assume 72 or 96 DPI when the tag is missing. That assumption only affects the default placed size in a layout app, not print quality.
  • Bleed: add the bleed to the print size before checking. An 8 × 8 in poster with 0.125 in bleed is 8.25 × 8.25 in, which needs 2475 px per side.
  • Vector-looking art is still raster: a flat "print-ready" AI image is a PNG; scaling it up does not behave like SVG.

Getting a fixture of your own

If you want to run the same check on your own generated poster, the GPT Image 2 prompt workspace has the festival prompt in its library. When I checked on October 26, 2026, the generator defaulted to 3:4, one output and Low quality, which showed 5 credits; switching to High showed 45 credits for one image. Measure whatever file you download with the script instead of assuming its size. New creations are Public with a watermark by default.

Wrap-up

Check pixels, not tags: ceil(inches × 300) per side, using the weaker side when aspect ratios differ. Keep the DPI tag for layout convenience, and fail the build when the pixels are short. If you need a test poster to try it on, generate one from the same prompt library and run the checker on the download.

Top comments (0)