Coverage for gws-app/gws/lib/image/format.py: 100%
79 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Image formats.
3``PNG8``, ``PNG24``, ``JPEG_LOW``, ``JPEG``, ``JPEG_HIGH``, ``WEBP`` and ``WEBP_LOSSLESS`` are predefined ``gws.ImageFormat`` objects. The first
4MIME type of a format is its content type, the others are names under which services offer it
5(``image/png; mode=8bit``). ``from_config`` creates a format from a ``Config``, and ``best_mime_type``
6selects the MIME type to request from a remote source (WMS, QGIS Server) for a format.
8Encoding options
9----------------
11The options of a format are passed to ``gws.Image.to_bytes``. ``mode``, ``background`` and ``quality``
12are also ``Config`` fields. ``mode`` and ``background`` are handled by ``gws.lib.image.core.Image``,
13all other keys are passed to the PIL encoder (Pillow 12). Configured options replace those of a
14predefined format, except its ``mode``, ``background`` and ``quality``.
16==================== ================================================================= ================
17option effect formats
18==================== ================================================================= ================
19``mode`` convert to this PIL mode before encoding, with an adaptive all
20 palette: ``P`` (8-bit palette), ``RGBA``, ``RGB``, ``L``
21 (grayscale); JPEG accepts only ``RGB``, ``L``, ``CMYK``
22``background`` color of transparent areas when RGBA is converted to RGB, JPEG
23 default white
24``quality`` JPEG 1-95 (default 75), WebP 0-100 (default 80); for lossless JPEG, WebP, TIFF
25 WebP the encoder effort; TIFF with ``compression: jpeg`` only
26``compress_level`` zlib level 0-9, default 6; predefined PNG formats use 3 PNG
27``optimize`` optimized Huffman tables JPEG
28``progressive`` progressive JPEG JPEG
29``subsampling`` chroma subsampling: 0 = 4:4:4, 1 = 4:2:2, 2 = 4:2:0 JPEG
30``lossless`` lossless WebP WebP
31``method`` encoder effort 0-6, default 4 (higher = smaller, slower) WebP
32``alpha_quality`` alpha channel quality 0-100, default 100 WebP
33``compression`` ``raw``, ``tiff_lzw``, ``tiff_adobe_deflate``, ``jpeg``, TIFF
34 ``packbits``
35==================== ================================================================= ================
37Not to be used: PNG ``optimize`` and ``compress_level: 9`` (6-10x slower than level 6, at most 2%
38smaller), PNG ``bits`` and ``transparency`` (break the palette created by ``mode: P``), WebP ``exact``,
39JPEG ``quality`` above 95, ``smooth``, ``qtables``, ``restart_marker_*``, ``dpi`` (header only, no
40effect on maps), metadata and animation options, ``mode: P`` with WebP (converted back to RGBA).
42Measured on map tiles and a 1024 px aerial image: ``compress_level: 3`` encodes PNG24 2-3x and PNG8 3x
43faster than the default 6, at most 8% larger. Lossy WebP is 15-50% smaller than PNG8 and 6x slower to
44encode; lossless WebP is 2-4x smaller than PNG24, but larger and much slower than PNG8. JPEG on a
451024 px map: quality 50 is 35% smaller than 75, quality 90 is 70% larger; ``subsampling: 0`` adds
46another 40% at quality 90.
47"""
49from typing import Optional
51import gws
52import gws.lib.mime
54from . import core
57class ImageMode(gws.Enum):
58 """Image mode the image is converted to before encoding."""
60 P = 'P'
61 """8-bit palette."""
62 RGBA = 'RGBA'
63 """Full color with transparency."""
64 RGB = 'RGB'
65 """Full color without transparency."""
66 L = 'L'
67 """Grayscale."""
70class ImageFormatName(gws.Enum):
71 """Predefined image format."""
73 PNG8 = 'PNG8'
74 """PNG with an 8-bit palette."""
75 PNG24 = 'PNG24'
76 """PNG with full color and alpha."""
77 JPEG_LOW = 'JPEG_LOW'
78 """JPEG with quality 50."""
79 JPEG = 'JPEG'
80 """JPEG with quality 75."""
81 JPEG_HIGH = 'JPEG_HIGH'
82 """JPEG with quality 90."""
83 WEBP = 'WEBP'
84 """Lossy WebP."""
85 WEBP_LOSSLESS = 'WEBP_LOSSLESS'
86 """Lossless WebP."""
89class Config(gws.Config):
90 """Image format with its encoding options."""
92 name: ImageFormatName
93 """Predefined format."""
94 mimeTypes: Optional[list[gws.MimeType]]
95 """MIME types for this format, replace those of the predefined format."""
96 mode: Optional[ImageMode]
97 """Image mode."""
98 background: Optional[gws.Color]
99 """Color of transparent areas in formats without transparency (JPEG)."""
100 quality: Optional[int]
101 """Encoding quality: JPEG 1-95, WebP 0-100."""
102 options: Optional[dict]
103 """Further image encoding options."""
106PNG8 = gws.ImageFormat(
107 name=ImageFormatName.PNG8,
108 mimeTypes=[gws.lib.mime.PNG, gws.lib.mime.PNG8],
109 options={'mode': 'P', 'compress_level': 3},
110)
111PNG24 = gws.ImageFormat(
112 name=ImageFormatName.PNG24,
113 mimeTypes=[gws.lib.mime.PNG, gws.lib.mime.PNG24],
114 options={'compress_level': 3},
115)
116JPEG_LOW = gws.ImageFormat(
117 name=ImageFormatName.JPEG_LOW,
118 mimeTypes=[gws.lib.mime.JPEG],
119 options={'quality': 50},
120)
121JPEG = gws.ImageFormat(
122 name=ImageFormatName.JPEG,
123 mimeTypes=[gws.lib.mime.JPEG],
124 options={'quality': 75},
125)
126JPEG_HIGH = gws.ImageFormat(
127 name=ImageFormatName.JPEG_HIGH,
128 mimeTypes=[gws.lib.mime.JPEG],
129 options={'quality': 90},
130)
131WEBP = gws.ImageFormat(
132 name=ImageFormatName.WEBP,
133 mimeTypes=[gws.lib.mime.WEBP],
134 options={},
135)
136WEBP_LOSSLESS = gws.ImageFormat(
137 name=ImageFormatName.WEBP_LOSSLESS,
138 mimeTypes=[gws.lib.mime.WEBP],
139 options={'lossless': True},
140)
143_FORMATS = {f.name: f for f in [PNG8, PNG24, JPEG_LOW, JPEG, JPEG_HIGH, WEBP, WEBP_LOSSLESS]}
144_CONFIG_KEYS = ['mode', 'background', 'quality']
147def from_config(cfg: Config) -> gws.ImageFormat:
148 """Create an image format from its configuration.
150 The format is based on the predefined format ``name``. Configured MIME types replace those
151 of the predefined format, and so do configured ``options``, except that the predefined ``mode``,
152 ``background`` and ``quality`` are kept unless the configured ``options`` set them. The
153 configured ``mode``, ``background`` and ``quality`` fields are applied last.
155 Args:
156 cfg: Format configuration.
158 Returns:
159 The image format.
161 Raises:
162 ``Error``: If the name is not a predefined format or the configuration has no known MIME types.
163 """
165 name = cfg.get('name') or ''
166 base = _FORMATS.get(name)
167 if not base:
168 raise core.Error(f'unknown image format {name!r}')
170 mimes = base.mimeTypes
171 p = cfg.get('mimeTypes')
172 if p is not None:
173 mimes = []
174 for s in p:
175 mime = gws.lib.mime.get(s)
176 if not mime or not core.mime_to_pil_format(mime):
177 gws.log.warning(f'image format {name!r}: unknown MIME type {s!r} ignored')
178 continue
179 mimes.append(mime)
181 if not mimes:
182 raise core.Error(f'image format {name!r}: no known MIME types')
184 p = cfg.get('options')
185 options = dict(gws.u.to_dict(p) if p is not None else base.options)
187 for key in _CONFIG_KEYS:
188 p = cfg.get(key)
189 if p is not None:
190 options[key] = p
191 elif key not in options and key in base.options:
192 options[key] = base.options[key]
194 return gws.ImageFormat(name=name, mimeTypes=mimes, options=options)
197def find_by_mime_type(mime: gws.MimeType, formats: list[gws.ImageFormat]) -> Optional[gws.ImageFormat]:
198 """Find an image format by one of its MIME types.
200 Args:
201 mime: MIME type, normalized with ``gws.lib.mime.get``.
202 formats: Image formats.
204 Returns:
205 The first format that has the MIME type, or ``None`` if there is none.
206 """
208 for ifo in formats:
209 if mime in ifo.mimeTypes:
210 return ifo
213def best_mime_type(ifo: gws.ImageFormat, supported: list[str]) -> str:
214 """Select the MIME type to request from a source for an image format.
216 The supported MIME types are tried longest first, so that a specific one (``image/png; mode=8bit``)
217 wins over a plain one (``image/png``). The first one that is among the MIME types of the format
218 is selected.
220 Args:
221 ifo: Image format.
222 supported: MIME types the source supports, normalized with ``gws.lib.mime.get``.
224 Returns:
225 The selected MIME type, or an empty string if none matches.
226 """
228 for mime in sorted(supported, key=len, reverse=True):
229 if mime in ifo.mimeTypes:
230 return mime
232 return ''