Modern numpy protects you in one place and not in the other, and the difference is worth knowing exactly.
import numpy as np pixels = np.array([100, 200], dtype=np.uint8) # a uint8 holds 0 to 255 pixels + 100 # -> [200, 44] wrapped, and silently # 300 does not fit in a byte: 300 - 256 = 44 pixels.sum() # -> 300 NOT wrapped # reductions accumulate in a wider type by default, so sum, prod and # cumsum are safe. Element-wise arithmetic is not. np.iinfo(np.uint8).max # -> 255 the limit, from the dtype wide = pixels.astype(np.int64) + 100 # [200, 300] room to breathe np.clip(wide, 0, 255).astype(np.uint8) # [200, 255] clamped, narrowed
- Reductions are safe; element-wise arithmetic is not
numpy accumulates sum, prod and cumsum in a wider type by default, so .sum() on an array of int8s gives the right answer — people quote the opposite and it has not been true for some time. What does still wrap, silently and on every version, is `array + n`, `array * n` and every other element-wise operation, because the result must fit the same dtype. That is where to look.
- np.iinfo tells you the limits, so you do not hard-code them
np.iinfo(array.dtype).min and .max give the range for whatever integer dtype you were handed — 0 to 255 for uint8, −128 to 127 for int8. Writing 255 into the code makes the function silently wrong the day somebody passes a uint16, and there are ten integer dtypes to be wrong about.
- Widen, clip, narrow — in that order
The addition has to happen somewhere wide enough to hold the true result, or there is nothing left to clip; then the clamp is applied to honest numbers; then the result goes back to the original dtype for storage. Clipping before adding would clamp the wrong values, and narrowing before clipping would already have wrapped.
Three steps in order: do the arithmetic somewhere wide enough to hold the real answer, clamp it to what the original dtype can hold, then convert back.
np.iinfo(pixels.dtype) gives .min and .max for the dtype you were handed. astype(np.int64) gives room for the addition, np.clip applies the limits, and astype(pixels.dtype) returns it to storage.
Check the dtype kind first — 'i' and 'u' are the integer kinds — because np.iinfo raises a confusing error on a float dtype. Take the limits from pixels.dtype before widening, or you will be clipping to int64's range and clamping nothing at all.
import numpy as np def brighten(pixels, amount): if pixels.dtype.kind not in ('i', '____'): raise ____(f’expected an integer array, got {pixels.dtype}') limits = np.____(pixels.dtype) widened = pixels.astype(np.____) + amount clamped = np.____(widened, limits.min, limits.max) return clamped.astype(pixels.____)
import numpy as np
def brighten(pixels, amount):
if pixels.dtype.kind not in ("i", "u"):
raise TypeError(
f"expected an integer array, got {pixels.dtype}"
)
# The limits come from the dtype we were handed, before widening.
limits = np.iinfo(pixels.dtype)
# Widen, so the true result exists and can be judged; clip, so it is
# clamped rather than wrapped; narrow, so the result is storable.
widened = pixels.astype(np.int64) + amount
clamped = np.clip(widened, limits.min, limits.max)
return clamped.astype(pixels.dtype)
Once the array is int64, np.iinfo of its dtype gives ±9 quintillion and the clip does nothing at all — so the function would wrap on the final astype instead, which is where it started. Capturing the original dtype’s range first is the step that makes the rest work.
Every step here builds a new array: astype copies, the addition allocates, clip allocates. That is three temporary arrays the size of the image, which is the honest cost of not wrapping — and on a large image the reason you might instead use `np.add(pixels, amount, out=..., casting='unsafe')` deliberately, knowing exactly what you are trading.
There are ten integer dtypes and enumerating them is a list that will be wrong. `.kind` is one character: 'i' signed, 'u' unsigned, 'f' float, 'b' bool. Two characters cover every integer numpy has, on every platform.
Most of the time, do not use a narrow dtype — numpy defaults to int64 because it does not wrap for anything realistic. Narrow dtypes earn their place when size matters: pixels as uint8, audio as int16, a dataframe column shrunk to fit in memory. For image work specifically, libraries already do this: Pillow’s point() and OpenCV’s add() saturate rather than wrap, because clamping is what the domain means. And if you want wrapping to be an error rather than a surprise, np.errstate(over='raise') turns it into an exception for the block it wraps — worth switching on in tests even when you would not ship it.
- Take a multiplier as well as an offset and work out whether one clip at the end is still enough.
- Write the in-place version with np.add(..., out=pixels) and find out what casting rule it demands, then decide whether you would ship it.
- Run the naive `pixels + amount` inside np.errstate(over='raise') and see whether the element-wise overflow is actually reported — the answer depends on your numpy version.