Skip to content

Commit c26285c

Browse files
authored
✨ Add Noise/Grain effect (Feature 4) (#5)
1 parent ce25399 commit c26285c

13 files changed

Lines changed: 325 additions & 15 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,42 @@ canvas = Canvas(1280, 720).shape(
213213
)
214214
```
215215

216+
### Grain / noise effect
217+
218+
Add film-grain noise to background or image layers via `effects=[Grain(...)]`.
219+
220+
```python
221+
from quickthumb import Canvas, Grain
222+
223+
canvas = (
224+
Canvas(1280, 720)
225+
.background(
226+
color="#1A1A2E",
227+
effects=[Grain(intensity=0.12, monochrome=True)],
228+
)
229+
.image(
230+
path="portrait.png",
231+
position=("70%", "50%"),
232+
width=400,
233+
height=500,
234+
align=("center", "middle"),
235+
effects=[Grain(intensity=0.08, monochrome=False, blend_mode="overlay", opacity=0.6)],
236+
)
237+
)
238+
```
239+
240+
`Grain` parameters:
241+
242+
| Parameter | Type | Default | Description |
243+
| --- | --- | --- | --- |
244+
| `intensity` | `float` | required | Noise amplitude, `0.0``1.0` |
245+
| `monochrome` | `bool` | `True` | `True` = luminance noise; `False` = per-channel color noise |
246+
| `blend_mode` | `str` | `"overlay"` | `"overlay"`, `"screen"`, `"multiply"`, or `"normal"` |
247+
| `opacity` | `float` | `1.0` | Overall grain strength, `0.0``1.0` |
248+
| `seed` | `int \| None` | `None` | Optional RNG seed for deterministic output |
249+
250+
`Grain` is valid in `effects` on **background** and **image** layers. It is serialized with `"type": "grain"` in JSON.
251+
216252
### Export helpers
217253

218254
```python
@@ -323,6 +359,7 @@ os.environ["QUICKTHUMB_DEFAULT_FONT"] = "Roboto"
323359
| Fonts | Local fonts, CSS-style weights, italic/bold flags, webfont URLs, fallback mapping |
324360
| Images | Local/remote images, sizing, fit modes, alignment, opacity, rotation |
325361
| Image effects | Stroke, shadow, glow, filter effects, border radius, background removal |
362+
| Grain / noise | Per-layer `Grain` effect on background and image layers; monochrome or color noise |
326363
| Shapes | Rectangle and ellipse primitives with stroke/shadow/glow support |
327364
| Export | PNG, JPEG, WebP, file output, base64, data URLs |
328365
| Serialization | `to_json()` / `from_json()` for built-in layer types and named custom layers |
@@ -342,6 +379,8 @@ See the shipped examples in [`examples/README.md`](examples/README.md):
342379
- `position` percentage values must be strings like `"50%"`
343380
- `fill` and `color` are independent fields; when `fill` is set it takes visual precedence over `color`
344381
- `canvas.custom(fn)` without a `name` runs during render order but cannot be serialized to JSON; pass `name=` and register the function with `Canvas.register_layer_fn()` to enable serialization
382+
- `Grain` is valid only on background and image layer `effects`; it is not a valid text or shape effect
383+
- `Grain(intensity=0.0)` is a no-op (no noise is generated or composited)
345384

346385
## Development
347386

docs/api/background.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ canvas.background(
2626
| `opacity` | `float` | `1.0` | Layer opacity from `0.0` (transparent) to `1.0` (opaque). |
2727
| `blend_mode` | `str \| BlendMode \| None` | `None` | How this layer composites over previous layers. See [BlendMode](enums.md#blendmode). |
2828
| `fit` | `str \| FitMode \| None` | `None` | How an image fills the canvas. See [FitMode](enums.md#fitmode). |
29-
| `effects` | `list[Filter] \| None` | `[]` | Background effects. Currently only `Filter` is supported. |
29+
| `effects` | `list \| None` | `[]` | Background effects. Accepts `Filter` and `Grain`. |
3030

3131
## Examples
3232

@@ -75,4 +75,4 @@ canvas.background(color="#000000", opacity=0.4)
7575
- `color`, `gradient`, and `image` can each be used independently or together within one layer.
7676
- `blend_mode` applies when compositing this layer over previous layers.
7777
- Supports both local file paths and remote URLs for `image`.
78-
- For available effects, see [Filter](effects.md#filter).
78+
- For available effects, see [Filter](effects.md#filter) and [Grain](effects.md#grain).

docs/api/effects.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Effects are modifiers applied to layers. Each layer type accepts a specific set.
99
| `Glow` |||||
1010
| `Filter` |||||
1111
| `Background` |||||
12+
| `Grain` |||||
1213

1314
Pass effects as a list to the `effects` parameter of any layer:
1415

@@ -193,3 +194,50 @@ canvas.text(
193194

194195
!!! note
195196
`Background` as an effect is separate from the `.background()` layer builder. The effect applies behind a text block; the layer covers the full canvas.
197+
198+
---
199+
200+
## Grain
201+
202+
Adds film-grain noise to a background or image layer.
203+
204+
```python
205+
from quickthumb import Grain
206+
207+
Grain(intensity=0.12, monochrome=True, blend_mode="overlay", opacity=1.0)
208+
```
209+
210+
| Parameter | Type | Default | Description |
211+
| --- | --- | --- | --- |
212+
| `intensity` | `float` | **required** | Noise amplitude from `0.0` (none) to `1.0` (maximum). |
213+
| `monochrome` | `bool` | `True` | `True` = luminance noise (grey grain); `False` = independent per-channel color noise. |
214+
| `blend_mode` | `str` | `"overlay"` | How noise composites onto the layer. One of `"overlay"`, `"screen"`, `"multiply"`, `"normal"`. |
215+
| `opacity` | `float` | `1.0` | Overall grain strength from `0.0` (invisible) to `1.0` (full). |
216+
| `seed` | `int \| None` | `None` | RNG seed for deterministic output. `None` = random each render. |
217+
218+
`intensity=0.0` is a no-op — no noise is generated.
219+
220+
### Example
221+
222+
```python
223+
from quickthumb import Canvas, Grain
224+
225+
canvas = (
226+
Canvas(1280, 720)
227+
.background(
228+
color="#1A1A2E",
229+
effects=[Grain(intensity=0.12, monochrome=True)],
230+
)
231+
.image(
232+
path="portrait.png",
233+
position=("70%", "50%"),
234+
width=400,
235+
height=500,
236+
align=("center", "middle"),
237+
effects=[Grain(intensity=0.08, monochrome=False, blend_mode="overlay", opacity=0.6)],
238+
)
239+
)
240+
```
241+
242+
!!! note
243+
`Grain` is valid only on **background** and **image** layers. It is not available on text or shape layers.

docs/api/image.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ canvas.image(
3636
| `remove_background` | `bool` | `False` | Remove the image background using AI. Requires `quickthumb[rembg]`. |
3737
| `border_radius` | `int` | `0` | Corner rounding in pixels. Non-negative integer. |
3838
| `blend_mode` | `str \| BlendMode \| None` | `None` | Compositing blend mode. See [BlendMode](enums.md#blendmode). |
39-
| `effects` | `list \| None` | `[]` | List of effects: `Stroke`, `Shadow`, `Glow`, `Filter`. |
39+
| `effects` | `list \| None` | `[]` | List of effects: `Stroke`, `Shadow`, `Glow`, `Filter`, `Grain`. |
4040

4141
## Examples
4242

docs/api/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ from quickthumb import (
1313
Filter,
1414
FitMode,
1515
Glow,
16+
Grain,
1617
LinearGradient,
1718
RadialGradient,
1819
Shadow,
@@ -33,7 +34,7 @@ from quickthumb import (
3334
| [Image](image.md) | `.image()` — overlay images and cutouts |
3435
| [Shape](shape.md) | `.shape()` — rectangles and ellipses |
3536
| [Outline](outline.md) | `.outline()` — canvas border |
36-
| [Effects](effects.md) | `Stroke`, `Shadow`, `Glow`, `Filter`, `Background` |
37+
| [Effects](effects.md) | `Stroke`, `Shadow`, `Glow`, `Filter`, `Background`, `Grain` |
3738
| [Enums & Gradients](enums.md) | `Align`, `BlendMode`, `FitMode`, `LinearGradient`, `RadialGradient`, `TextFillImage` |
3839

3940
## Error types

docs/json-schema.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,13 @@ Effects are embedded in each layer's `"effects"` array and use a `"type"` discri
266266
{ "type": "background", "color": "#111827CC", "padding": [16, 24], "border_radius": 14, "opacity": 1.0 }
267267
```
268268

269+
=== "Grain"
270+
```json
271+
{ "type": "grain", "intensity": 0.12, "monochrome": true, "blend_mode": "overlay", "opacity": 1.0 }
272+
```
273+
274+
`blend_mode` values: `"overlay"`, `"screen"`, `"multiply"`, `"normal"`. Optional `"seed"` integer for deterministic output.
275+
269276
## Complete example
270277

271278
A full YouTube-style thumbnail spec:
@@ -349,7 +356,7 @@ Generate a QuickThumb JSON config for a 1280×720 YouTube thumbnail.
349356
Rules:
350357
- Top-level fields: "width", "height", "layers"
351358
- Every layer must have a "type" field: "background", "text", "image", "shape", or "outline"
352-
- Every effect must have a "type" field: "stroke", "shadow", "glow", "filter", or "background"
359+
- Every effect must have a "type" field: "stroke", "shadow", "glow", "filter", "background", or "grain"
353360
- Positions are [x, y] arrays — values can be integers (px) or percentage strings like "50%"
354361
- Colors are hex strings: "#RRGGBB" or "#RRGGBBAA"
355362
- Layers render bottom-to-top in array order

quickthumb/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
Filter,
99
FitMode,
1010
Glow,
11+
Grain,
1112
ImageEffect,
1213
ImageLayer,
1314
LinearGradient,
@@ -35,6 +36,7 @@
3536
"Filter",
3637
"FitMode",
3738
"Glow",
39+
"Grain",
3840
"ImageEffect",
3941
"ImageLayer",
4042
"LinearGradient",

quickthumb/canvas.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Filter,
2525
FitMode,
2626
Glow,
27+
Grain,
2728
ImageEffect,
2829
ImageLayer,
2930
LayerType,
@@ -575,7 +576,10 @@ def _render_background_layer(self, image: Image.Image, layer: BackgroundLayer):
575576
return
576577

577578
for effect in layer.effects:
578-
layer_image = self._apply_filter(layer_image, effect)
579+
if isinstance(effect, Grain):
580+
layer_image = self._apply_grain(layer_image, effect)
581+
else:
582+
layer_image = self._apply_filter(layer_image, effect)
579583

580584
if layer.opacity < 1.0 and not layer.color:
581585
layer_image = self._apply_opacity(layer_image, layer.opacity)
@@ -652,6 +656,90 @@ def _apply_filter(self, image: Image.Image, effect: Filter) -> Image.Image:
652656
image = self._apply_saturation(image, effect.saturation)
653657
return image
654658

659+
@staticmethod
660+
def _generate_noise_image(
661+
size: tuple[int, int],
662+
intensity: float,
663+
monochrome: bool,
664+
seed: int | None,
665+
) -> Image.Image | None:
666+
import random as _random
667+
668+
pixel_count = size[0] * size[1]
669+
max_val = int(intensity * 255)
670+
671+
if max_val == 0:
672+
return None
673+
674+
lut = bytes(i * max_val // 255 for i in range(256))
675+
676+
if seed is not None:
677+
rng = _random.Random(seed)
678+
if monochrome:
679+
raw = rng.randbytes(pixel_count)
680+
else:
681+
raw_r, raw_g, raw_b = (
682+
rng.randbytes(pixel_count),
683+
rng.randbytes(pixel_count),
684+
rng.randbytes(pixel_count),
685+
)
686+
else:
687+
if monochrome:
688+
raw = os.urandom(pixel_count)
689+
else:
690+
raw_r, raw_g, raw_b = (
691+
os.urandom(pixel_count),
692+
os.urandom(pixel_count),
693+
os.urandom(pixel_count),
694+
)
695+
696+
if monochrome:
697+
ch = Image.frombytes("L", size, raw).point(lut)
698+
noise_img = Image.merge("RGB", [ch, ch, ch])
699+
else:
700+
noise_img = Image.merge(
701+
"RGB",
702+
[
703+
Image.frombytes("L", size, raw_r).point(lut),
704+
Image.frombytes("L", size, raw_g).point(lut),
705+
Image.frombytes("L", size, raw_b).point(lut),
706+
],
707+
)
708+
return noise_img.convert("RGBA")
709+
710+
def _blend_grain(
711+
self,
712+
image: Image.Image,
713+
intensity: float,
714+
monochrome: bool,
715+
seed: int | None,
716+
blend_mode: str,
717+
opacity: float,
718+
) -> Image.Image:
719+
if opacity == 0.0:
720+
return image
721+
noise = self._generate_noise_image(image.size, intensity, monochrome, seed)
722+
if noise is None:
723+
return image
724+
r, g, b, original_alpha = image.split()
725+
blended = self._apply_blend_mode(image, noise, blend_mode)
726+
br, bg, bb, _ = blended.split()
727+
if opacity < 1.0:
728+
br = Image.blend(r, br, opacity)
729+
bg = Image.blend(g, bg, opacity)
730+
bb = Image.blend(b, bb, opacity)
731+
return Image.merge("RGBA", (br, bg, bb, original_alpha))
732+
733+
def _apply_grain(self, image: Image.Image, effect: Grain) -> Image.Image:
734+
return self._blend_grain(
735+
image,
736+
effect.intensity,
737+
effect.monochrome,
738+
effect.seed,
739+
effect.blend_mode,
740+
effect.opacity,
741+
)
742+
655743
def _apply_opacity_to_color(self, color: tuple[int, ...], opacity: float) -> tuple[int, ...]:
656744
r, g, b = color[:3]
657745

@@ -799,6 +887,8 @@ def _render_image_layer(self, image: Image.Image, layer: ImageLayer):
799887
for effect in layer.effects:
800888
if isinstance(effect, Filter):
801889
img = self._apply_filter(img, effect)
890+
elif isinstance(effect, Grain):
891+
img = self._apply_grain(img, effect)
802892

803893
for effect in layer.effects:
804894
if isinstance(effect, Glow):

quickthumb/models.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,39 @@ def validate_saturation(cls, v: float) -> float:
261261
return v
262262

263263

264+
_GRAIN_BLEND_MODES = frozenset({"overlay", "screen", "multiply", "normal"})
265+
266+
267+
class Grain(QuickThumbModel):
268+
type: Literal["grain"] = "grain"
269+
intensity: float
270+
monochrome: bool = True
271+
blend_mode: str = "overlay"
272+
opacity: OpacityField = 1.0
273+
seed: int | None = None
274+
275+
@field_validator("intensity")
276+
@classmethod
277+
def validate_intensity(cls, v: float) -> float:
278+
if v < 0.0 or v > 1.0:
279+
raise ValueError("intensity must be between 0.0 and 1.0")
280+
return v
281+
282+
@field_validator("blend_mode")
283+
@classmethod
284+
def validate_blend_mode(cls, v: str) -> str:
285+
if v not in _GRAIN_BLEND_MODES:
286+
raise ValueError(f"blend_mode must be one of: {', '.join(sorted(_GRAIN_BLEND_MODES))}")
287+
return v
288+
289+
264290
TextEffect = Annotated[Stroke | Shadow | Glow | Background, Discriminator("type")]
265291

266-
ImageEffect = Annotated[Stroke | Shadow | Glow | Filter, Discriminator("type")]
292+
ImageEffect = Annotated[Stroke | Shadow | Glow | Filter | Grain, Discriminator("type")]
267293

268294
ShapeEffect = Annotated[Stroke | Shadow | Glow, Discriminator("type")]
269295

270-
BackgroundEffect = Filter
296+
BackgroundEffect = Annotated[Filter | Grain, Discriminator("type")]
271297

272298

273299
class TextPart(QuickThumbModel):

specs/SPEC.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ This document specifies planned and exploratory features for QuickThumb. It is a
1515

1616
| # | Feature | Status |
1717
| --- | ---------------------------- | ------------- |
18-
| 1 | CLI (`quickthumb` command) | `planned` |
19-
| 2 | Template System | `planned` |
18+
| 1 | CLI (`quickthumb` command) | `done` |
19+
| 2 | Template System | `done` |
2020
| 3 | Gradient / Image-Filled Text | `done` |
21-
| 4 | Noise / Grain Effect | `planned` |
21+
| 4 | Noise / Grain Effect | `done` |
2222
| 5 | Presentation & Video | `exploratory` |
2323

2424
---
2525

26-
## 1. CLI — `planned`
26+
## 1. CLI — `done`
2727

2828
A `quickthumb` command-line tool for rendering JSON specs without writing Python.
2929

@@ -108,7 +108,7 @@ uv pip install "quickthumb[cli]"
108108

109109
---
110110

111-
## 2. Template System — `planned`
111+
## 2. Template System — `done`
112112

113113
Reusable JSON specs with variable placeholders. Useful for batch generation and AI-driven workflows.
114114

@@ -350,7 +350,7 @@ Fallback rule: if `fill` is `None`, `color` is used as before.
350350

351351
---
352352

353-
## 4. Noise / Grain Effect — `planned`
353+
## 4. Noise / Grain Effect — `done`
354354

355355
Add film-grain noise to backgrounds, images, or the entire canvas.
356356

0 commit comments

Comments
 (0)