Raymarched gold torus
Twisted teal ceramic bar
Vermilion satin sphere
Plum clay blob with displacement
Glazed cream vase from a lathe profile

No image model · just code

LetyourLLM
paint.

Ask an LLM for a picture and it calls an image model. atelier hands the brush to the LLM itself: it writes a short script, renders it, looks at the result and fixes it. Path-traced 3D, oil and watercolour, comics, people, landscapes, posters, pixel art, with no image model ever called.

$ pip install git+https://github.com/Wexop/atelier
0
image models called
0
API keys or GPUs
12
modules, one canvas
8
style guides for the agent
  • 3D still life, rendered by atelier3D still life
  • comic from 3D, rendered by ateliercomic from 3D
  • CMYK halftone, rendered by atelierCMYK halftone
  • eroded mountains, rendered by ateliereroded mountains
  • watercolour, rendered by atelierwatercolour
  • pixel art, rendered by atelierpixel art
  • posable people, rendered by atelierposable people
  • domain-warped silk, rendered by atelierdomain-warped silk
  • engraving, rendered by atelierengraving
  • duotone, rendered by atelierduotone
  • oil on canvas, rendered by atelieroil on canvas
  • copper, glass, wood, rendered by ateliercopper, glass, wood
  • night sky, rendered by ateliernight sky
  • retro16 dither, rendered by atelierretro16 dither

How it works

Render. Look. Fix. Repeat.

atelier is made for a model that can see. Every script renders a fast 512 px preview, the agent looks at it, zooms on a region at full resolution, fixes what is wrong, and renders again. Four passes on the same scene:

  1. 01

    Block it out

    The agent writes a 30–80 line scene: signed-distance primitives, a lathe profile for the pot, a camera. Grey clay, a default sun.

    pot = lathe([(0, 0), (.24, .01), (.3, .12), (.28, .3),
                (.16, .42), (.1, .5), (.14, .56)], thickness=.025)
    s.add(pot | spout, Mat("#9a9a9a", rough=.8))
    s.camera((.1, .75, 2.4), (.1, .18, 0), fov=32)

    $ atelier render still.py --preview · 7 s

  2. 02

    Give it materials

    Copper with metal=1, a glass ball that refracts the table, wood grain from a procedural field, subsurface scattering on the lemon.

    Mat("#c8743c", rough=.25, metal=1)            # copper
    Mat("#eef6f2", glass=1, ior=1.5)               # glass
    Mat(wood(rings=18).ramp([...]), proj="xz")     # table
    Mat("#f2c200", rough=.35, sss=.35)             # lemon

    $ looks flat → it needs a real light

  3. 03

    Light it like a photo

    A warm area light from the window side, a cool rim from the right, and an emissive panel off-camera so metal and glass have something to reflect.

    s.area(pos=(-2.6, 2.2, 1.6), size=(1.4, 1.8),
           color="#ffe6c2", power=50)              # key
    s.area(pos=(2.4, .8, 1.2), color="#7fc9d6", power=8)
    s.sky("#1d1d24", "#2c2620", "#100c09", power=.18)

    $ atelier render still.py --crop 380,300,400,260

  4. 04

    Finish and grade

    Depth of field from the depth buffer, focused where the agent points. Bloom on the highlights, a colour balance, vignette and film grain.

    r = c.render(s, aa=2, look="punchy")
    c.apply(depth_of_field(r.depth, r.focus_at(560, 470, c.s), 3.5),
            bloom(.82, 12, .35), vignette(.45), grain(.014))
    c.save("still.png")

    $ final 1200×750 · aa=2

Quick start

8 lines in. One night sky out.

A canvas, a gradient, a star field masked to the top, a ridged mountain line, bloom and grain. The code below is the README example, unchanged, and the picture next to it is what it renders.

night.py
from atelier import *

c = Canvas(1600, 1000)
c.fill(gradient([(0, "#0b1026"), (0.6, "#3b2a5a"), (1, "#f4a259")]))
c.layer(blend="screen").fill("white", mask=stars(70, seed=3) * top(0.6, soft=0.2))
c.layer().fill_below(ridged(2.2, seed=1).ridge_line(y=0.7, amp=0.15), fbm(6).ramp(["#1b1b2f", "#2e2e4d"]))
c.apply(bloom(0.8, 12), grain(0.03), vignette(0.3))
c.save("night.png")
$ atelier render night.py
final 1600x1000 -> night.png (4.3s)
Starry night sky over ridged mountains rendered by the night.py quick start
night.png1600 × 1000CPU · 4.3 s

One API, every medium

Fourteen styles. Zero diffusion.

The same canvas drives a path tracer, a brush engine, a vector renderer, terrain and sky simulations, posable figures and a stack of effects. Each piece below is one script, open it to read the code.

…and whatever
you can describe.

No guide for your look? The agent studies a reference, maps it onto the toolbox, paints it, then writes a new style guide for next time.

All example scripts ↗
Path-traced still life with a brass jug, a glass of white wine, apples, green grapes in a bowl and a lemon on an oak table
still_life_pt.py
# Path-traced still life: window light with bounce light, glass of white wine, brass jug, glazed bowl,
# fruit with blemishes, linen napkin (sheen), oak table with object-space grain. Uses c.pathtrace.
import numpy as np
from atelier import *

s = Scene()
f32 = np.float32
warp, pores, fleck = noise3((0.8, 3, 3), 3, seed=SEED + 1), noise3((2, 60, 60), 2, seed=SEED + 2), noise3(9, 3, seed=SEED + 3)


def oak(p, n):                          # rings around an axis below the board -> long flowing grain (object space)
    r = np.hypot(p[:, 1] + 0.42, p[:, 2] * 0.55 + 0.8) * 34 + (warp(p) - 0.5) * 7
    ring = (0.5 + 0.5 * np.sin(r * 2 * np.pi)) ** 3
    t = np.clip(0.25 + 0.55 * ring + (pores(p) - 0.5) * 0.9 + (fleck(p) - 0.5) * 0.35, 0, 1)[:, None]
    return np.array([0.50, 0.33, 0.19], f32) * (1 - t) + np.array([0.27, 0.15, 0.07], f32) * t


oak_rough = lambda p, n: 0.32 + 0.22 * pores(p)
s.add(round_box(3.2, 0.08, 1.5, 0.012).move(0, -0.04, 0.05),
      Mat(oak, rough=oak_rough, local=True, bump=0.25, bump_scale=90, wear=[scratches(0.25, 25, SEED), dust(0.15)]))
s.add(plane(-0.75, (0, 0, 1)), Mat("#6f6253", rough=0.95, bump=0.1, bump_scale=12))          # plaster wall

jug = lathe([(0.15, 0), (0.19, 0.1), (0.185, 0.3), (0.12, 0.45), (0.1, 0.56), (0.125, 0.62)], thickness=0.016)
handle = torus(0.105, 0.016).rotate(x=90).scale((0.8, 1.4, 1)).move(0.19, 0.36, 0)
s.add((jug | handle).rotate(y=-25).move(-0.62, 0, -0.38),
      Mat("#c9a466", rough=0.26, metal=1, local=True, wear=[smudges(0.35, 5, SEED), scratches(0.25, 70, SEED)]))


def bowl_r(u):                          # tulip wine-glass bowl centre line, u = 0 (bottom) .. 1 (rim)
    return 0.064 * np.sin(np.pi / 2 * np.minimum(1, u / 0.6)) ** 0.55 * (1 - 0.22 * np.maximum(0, u - 0.6) / 0.4)


u = np.linspace(0, 1, 70)
y0, y1, th = 0.112, 0.27, 0.0035
foot = [(0.043, 0.0015), (0.041, 0.0045), (0.012, 0.008), (0.005, 0.016), (0.0038, 0.06), (0.0042, 0.1), (0.002, y0)]
glass = lathe(foot + [(bowl_r(v) + 0.002, y0 + v * (y1 - y0)) for v in u[1:]], thickness=th, smooth=False)
fill = 0.19
uw = u[u * (y1 - y0) + y0 <= fill]
wine = lathe([(max(bowl_r(v) - th / 2 - 0.002, 0.0), y0 + 0.003 + v * (y1 - y0)) for v in uw[1:]], smooth=False)
gx, gz = 0.08, -0.22
glass, wine = glass.scale(1.35), wine.scale(1.35)
s.add(glass.move(gx, 0, gz), Mat("#f6faf8", glass=1, rough=0.0, ior=1.5))
s.add(wine.move(gx, 0, gz), Mat("#f0c860", glass=1, rough=0.0, ior=1.34))

bowl = lathe([(0.055, 0.0), (0.11, 0.015), (0.19, 0.06), (0.235, 0.13)], thickness=0.014)
s.add(bowl.move(0.6, 0, -0.2), Mat("#ece5d6", rough=0.1, sss=0.15, bump=0.1, bump_scale=30,
                                   wear=[edge_wear(0.25, 0.006, "#c9b9a0", SEED)]))
rng = np.random.default_rng(SEED)
grapes = None
for i in range(14):
    a, r = rng.uniform(0, 6.28), rng.uniform(0, 0.11)
    g = sphere(0.03).scale((1, 1.12, 1)).move(0.6 + r * np.cos(a), 0.1 + rng.uniform(0, 0.06) + (0.11 - r) * 0.45,
                                             -0.2 + r * np.sin(a))
    grapes = g if grapes is None else grapes | g
s.add(grapes, Mat("#a3ab4e", rough=0.18, sss=0.55, sss_color="#c8d060", wear=[dust(0.5, seed=SEED)]))

blush, streak, dots = noise3(5, 3, seed=SEED + 7), noise3((40, 5, 40), 2, seed=SEED + 8), noise3(140, 1, seed=SEED + 9)


def apple(p, n):                        # yellow-green base, red blush, vertical streaks, pale lenticels
    t = np.clip(0.55 + (blush(p) - 0.5) * 2.4 + (streak(p) - 0.5) * 0.9, 0, 1)[:, None]
    c = np.array([0.80, 0.70, 0.28], f32) * (1 - t) + np.array([0.55, 0.06, 0.05], f32) * t
    return c + np.clip((dots(p) - 0.72) * 5, 0, 1)[:, None] * 0.25


for (x, z, sc, ry) in [(-0.2, 0.2, 1.0, 0), (0.25, 0.3, 0.9, 70)]:
    ap = sphere(0.13 * sc).scale((1, 0.88, 1)).sub(sphere(0.04 * sc).move(0, 0.125 * sc, 0), k=0.045 * sc)
    s.add(ap.rotate(y=ry).move(x, 0.114 * sc, z), Mat(apple, rough=0.32, sss=0.3, sss_color="#d0401c", local=True,
                                                         bump=0.12, bump_scale=50, wear=[smudges(0.4, 4, SEED)]))
    s.add(capsule((0, 0, 0), (0.014, 0.055, 0.004), 0.0055).move(x, 0.2 * sc, z), Mat("#4a3420", rough=0.7))
lemon = ellipsoid(0.1, 0.07, 0.07).rotate(y=25)        # pores via bump (cheaper than displacement)
s.add(lemon.move(0.56, 0.07, 0.36), Mat("#e8b10c", rough=0.38, sss=0.3, bump=0.6, bump_scale=110, local=True))

nb = round_box(0.5, 0.012, 0.36, 0.005)                 # gentle analytic folds (cheap to trace)
napkin = SDF(lambda p: nb.f(p) + 0.005 * np.sin(p[:, 0] * 13 + 1) * np.sin(p[:, 2] * 10), nb.bounds, 0.8).rotate(y=14)
s.add(napkin.move(0.5, 0.006, 0.27), Mat(fabric(90).ramp(["#cfc6b4", "#e4dccb"]), proj="xz", tex_scale=0.3,
                                         rough=0.85, sheen=0.7, sheen_color="#fff6e8", local=True))
knife = round_box(0.3, 0.005, 0.026, 0.0025).move(0.06, 0, 0) | round_box(0.12, 0.018, 0.024, 0.008).move(-0.16, 0.006, 0)
s.add(knife.rotate(y=-18).move(-0.42, 0.004, 0.42), Mat("#c8ccd0", rough=0.16, metal=1, wear=[scratches(0.7, 90, SEED)]))

s.studio(key=(-1.9, 1.5, 1.5), target=(0.05, 0.2, -0.1), size=(1.0, 1.4), power=26, window=True, fill=0.08,
         bg="#2a241e")
s.camera((0.12, 0.56, 2.05), (0.06, 0.19, -0.1), fov=27)

c = Canvas(1200, 800)
r = c.pathtrace(s, spp=8, bounces=4, exposure=0.45, look="punchy", seed=SEED, threads=2, verbose=True)
c.apply(depth_of_field(r.depth, focus=r.focus_at(560, 470, c.s), aperture=2.2), bloom(0.85, 10, 0.2),
        vignette(0.4), grain(0.01, seed=SEED))
c.save(__file__[:-3] + ".png")
Flat graphic poster of an orange planet with orbits and the word ORBIT
poster.py
# Flat graphic poster: geometric composition, halftone-shaded planet, typographic grid.
import numpy as np
from atelier import *

W, H = 1200, 1700
navy, cream, gold, coral, teal = "#14213d", "#f4ecd8", "#fca311", "#e85d4a", "#2a9d8f"
c = Canvas(W, H, bg=navy)
c.scatter("dot", 260, region=top(0.62), size=(1.5, 4), color=cream, seed=SEED + 1)    # stars

cx, cy, R = 700, 620, 330
sky = rect(0, 0, W, 1110)
for i, r in enumerate([430, 520, 610]):                                               # orbits, clipped above the rule
    c.draw(circle(cx, cy, r).outline(2, dash=(2, 12) if i % 2 else None) & sky, fill=cream, opacity=0.45)
planet = circle(cx, cy, R)
c.draw(planet, fill=gradient([gold, coral], angle=120).within(planet))

def lambert(u, v):                       # sphere lit from the upper left, in the planet's local [0,1] box
    nx, ny = u * 2 - 1, v * 2 - 1
    nz = np.sqrt(np.clip(1 - nx * nx - ny * ny, 0, 1))
    return np.clip((-0.55 * nx - 0.45 * ny + 0.7 * nz) * 1.4, 0, 1) ** 1.2
shadow = c.layer(blend="multiply")
shadow.fill(Field(lambert).within(planet), mask=planet)
shadow.apply(halftone(9, 30, ink="#1b2440", paper="white"))
c.layer()                                                # back to a normal layer for the rest

c.circle(cx - 470, cy + 330, 46, fill=teal)                                           # moons on orbits
c.circle(cx + 398, cy - 334, 22, fill=cream)
c.circle(cx - 573, cy - 209, 14, fill=coral)

c.line(80, 1135, W - 80, 1135, stroke=cream, width=3)
c.text("ORBIT", 80, 1400, fit=W - 330, weight="bold", fill=cream, spacing=-0.01)
c.text("STARGAZING NIGHT", 80, 1490, 38, weight="semibold", fill=gold, spacing=0.18)
c.text("Pic Observatory\nSaturday, August 14 — 9 pm", 80, 1565, 30, fill=cream, line_height=1.4)
c.text("Telescopes open\nto the public · free", W - 80, 1565, 30, fill=cream, align="right", line_height=1.4)
c.text("No. 07", 80, 130, 34, weight="semibold", fill=cream, spacing=0.1)
c.apply(grain(0.02, 1.5, seed=SEED))
c.save(__file__[:-3] + ".png")
Impressionist oil painting of a summer field, a river and poplars
landscape.py
# Oil landscape: a field-built colour study repainted with oil strokes (coarse to fine).
import numpy as np
from atelier import *

W, H = 1500, 1000
ref = Canvas(W, H)
ref.fill(gradient([(0, "#4f78b0"), (0.35, "#86a6cc"), (0.55, "#f0d2a6")]))                  # sky
cl = clouds(1.8, 0.42, sharp=3, seed=SEED + 3).scale(2.2, 0.8) * top(0.48, 0.08)
ref.fill(mix(gradient(["#fff6e8", "#cdbfc4"], angle=90), "#b7aebd", fbm(4, seed=SEED + 4).remap(0.4, 0.8)),
         mask=cl)                                                                              # lit cumulus, grey bellies
ref.fill_below(fbm(2, seed=SEED + 5).ridge_line(0.55, 0.08), "#8f95b8")                       # far hills
ref.fill_below(fbm(3, seed=SEED + 6).ridge_line(0.6, 0.05), "#6d8566")                        # mid hills
ref.fill_below(curve(lambda x: 0.62 + 0 * x), fbm(4, seed=SEED + 7).ramp(["#a98f3c", "#d1b25a", "#e4c878"]))
ref.fill("#7f8f45", mask=smoothstep(0.5, 0.75, fbm(3, seed=SEED + 8)) * bottom(0.63, 0.02) * 0.5)            # green patches
ref.fill("#6b6040", mask=clouds(1.5, 0.35, seed=SEED + 9).scale(2.5, 0.6) * bottom(0.66, 0.03) * 0.45)  # cloud shadows
river = svg("M 300 1000 C 520 900 980 860 820 760 S 640 650 760 620 L 790 620 C 700 660 900 760 940 790"
            " S 760 920 560 1000 Z")
ref.draw(river, fill=gradient(["#b9cde4", "#e9dcc2"], angle=-90).within(river))
for x, h in [(1040, 230), (1095, 270), (1150, 250), (1205, 210), (1255, 240)]:                # poplars
    tree = ellipse(x, 620 - h / 2, 26, h / 2)
    ref.draw(tree, fill=gradient(["#2f4a2a", "#5f7a3a"], angle=0).within(tree))
    ref.draw(ellipse(x + 60, 640, 70, 9), fill="#7d7140", opacity=0.7)                        # cast shadows
ref.draw(ellipse(260, 560, 120, 95), fill=gradient(["#3d5530", "#6e8a45"], angle=-20).within(rect(140, 465, 240, 190)))
ref.rect(250, 600, 14, 70, fill="#4a3b2a")                                                    # lone tree trunk
ref.fill("#6a5a2e", mask=band(0.86, 1.0, 0.08) * 0.35)                                        # darker foreground

c = Canvas(W, H, bg="#e9dfcc")
c.paint_like(ref, "oil", sizes=(64, 32, 16, 9), tol=0.08, seed=SEED)
c.cur.relief = 0.6                                                                  # gentler impasto
c.apply(curves([(0.2, 0.17), (0.8, 0.84)]), vignette(0.25), grain(0.015, seed=SEED))
c.save(__file__[:-3] + ".png")
ref.preview(__file__[:-3] + "_ref.jpg", 768)
Watercolour of snowy mountains above a lake framed by pine trees
watercolor.py
# Watercolour: alpine lake, broad washes light -> dark, glazed ridges, ink pines, paper tooth.
import numpy as np
from atelier import *
from _common import *

W, H = 1500, 1000
c = Canvas(W, H, bg="#f4efe4")
rng = np.random.default_rng(SEED)
sky = c.layer(blend="multiply")
sky.fill(gradient([(0, "#9dbbd6"), (0.32, "#d9e2e6"), (0.45, "#f1d2b0")]), mask=top(0.5, soft=0.05) * 0.8)
for k in range(5):                                                                   # sky washes, top-heavy
    y = 40 + k * 80
    sky.stroke([(-40, y + rng.uniform(-20, 20)), (W / 2, y + rng.uniform(-30, 30)), (W + 40, y + rng.uniform(-20, 20))],
               "watercolor", size=170 - k * 18, color="#6f9bc2" if k < 3 else "#e9b78f", opacity=0.32 - k * 0.04, seed=SEED + k)
ridges = [(0.42, 0.16, "#8c8fb8", 0.7), (0.5, 0.12, "#5f6f99", 0.8), (0.58, 0.07, "#3f5a6e", 0.85)]
for i, (y, amp, col, op) in enumerate(ridges):                                       # mountain glazes
    L = c.layer(blend="multiply", opacity=op)
    crest = ridged(2.2 + i, 4, seed=SEED + 10 + i).ridge_line(y, amp)
    L.fill_below(crest, mix(col, "#f4efe4", fbm(5, seed=SEED + 20 + i) * 0.35))
    L.apply(displace(fbm(9, 3, seed=SEED + 30 + i), 5), blur(1.2))
    L.erase(bottom(0.645, soft=0.01))                                                # stop at the far shore
snow = c.layer(opacity=0.7)
snow.fill_below(ridged(2.2, 4, seed=SEED + 10).ridge_line(0.42, 0.16), "#f8f5ee",)
snow.erase(band(0.5, 1.0, 0.12) + (1 - smoothstep(0.55, 0.75, fbm(14, 3, seed=SEED + 40))))
lake_y = 640
lake = c.layer(blend="multiply")
lake.fill(gradient([(0, "#c9d6dd"), (0.4, "#8fb0c8"), (1, "#4f7392")]).within(rect(0, lake_y, W, H - lake_y)),
          mask=rect(0, lake_y, W, H - lake_y))
for k in range(6):                                                                   # horizontal lake strokes
    y = lake_y + 40 + k * 60
    lake.stroke([(-30, y), (W * 0.5, y + rng.uniform(-6, 6)), (W + 30, y)], "watercolor", size=40,
                color="#5f86a6", opacity=0.18 + k * 0.03, seed=SEED + 60 + k)
ink = c.layer(blend="multiply")
for x0 in list(range(30, 420, 46)) + list(range(1040, 1480, 52)):                   # pine forest on both shores
    h = rng.uniform(120, 230) * (1.2 if x0 < 300 or x0 > 1300 else 0.85)
    base = lake_y + rng.uniform(-6, 10)
    ink.stroke([(x0, base), (x0 + rng.uniform(-3, 3), base - h * 0.8)], "ink", size=4, color="#1f2e2a", taper=(0, 0.9), seed=int(x0))
    for j in range(9):
        t = j / 9
        yy = base - h * (0.1 + t * 0.85)
        w = (1 - t) * h * 0.24 + 6
        ink.stroke([(x0 - w, yy + w * 0.35), (x0, yy - 4), (x0 + w, yy + w * 0.35)], "watercolor", size=14 + (1 - t) * 10,
                   color="#22413a", opacity=0.75, seed=int(x0) + j)
    ink.stroke([(x0, base + 4), (x0, base + 4 + h * 0.55)], "watercolor", size=h * 0.2, color="#2c4a4a", opacity=0.16, taper=(0, 0.8),
               seed=int(x0) + 99)                                                     # reflection
c.layer(blend="overlay", opacity=0.35).fill(paper())
c.layer()
c.apply(curves([(0.15, 0.12), (0.85, 0.88)]), grain(0.012, seed=SEED))
c.save(out("watercolor.jpg"), quality=90)
Pixel art of a lakeside cabin at sunset with pines and a reflected sun
pixel.py
# Pixel art: 320x180, 16-colour palette, ordered dithering, exported 4x without smoothing.
import numpy as np
from atelier import *

PAL = ["#14102a", "#241a45", "#3d2a63", "#6b3a7a", "#a54d79", "#e0716a", "#f6a46b", "#ffd88a",
       "#fff4c8", "#2b2148", "#433467", "#1a2a33", "#21403a", "#2f6050", "#5a3524", "#ffe08a"]
c = Canvas(320, 180, pixel=True)
sky = c.cur
sky.fill(gradient([(0, PAL[0]), (0.25, PAL[2]), (0.45, PAL[4]), (0.6, PAL[6]), (0.68, PAL[7])]))
sky.circle(196, 112, 22, fill=PAL[8])                                    # setting sun
sky.apply(pixelate(1, PAL[:9], dither="bayer", strength=0.35))
c.layer().scatter("square", 40, region=top(0.3), size=(1, 1), color=PAL[8], seed=SEED)  # stars

far = fbm(3, 3, seed=SEED + 2).ridge_line(0.52, 0.22)
c.layer().fill_below(far, PAL[10])
near = ridged(4, 4, seed=SEED + 4).ridge_line(0.64, 0.16)
c.layer().fill_below(near, PAL[9])
land = c.layer()
land.rect(0, 120, 320, 60, fill=PAL[9])                                   # shore line base
for x, h in [(18, 34), (34, 46), (52, 30), (262, 40), (280, 52), (300, 36)]:   # pines
    for k in range(4):
        w = h * (0.45 - k * 0.08)
        land.polygon([(x - w, 124 - k * h / 5), (x + w, 124 - k * h / 5), (x, 124 - k * h / 5 - h / 2.6)], fill=PAL[11])
    land.rect(x - 1, 118, 3, 7, fill=PAL[14])
land.polygon([(226, 112), (240, 100), (254, 112)], fill=PAL[14])         # cabin
land.rect(229, 112, 22, 12, fill=PAL[14])
land.rect(236, 116, 5, 4, fill=PAL[15])                                   # lit window
land.rect(246, 100, 3, 6, fill=PAL[14])                                   # chimney + smoke
for k, (dx, dy) in enumerate([(0, 0), (2, -4), (5, -8), (9, -11), (14, -13)]):
    land.rect(247 + dx, 96 + dy, 2 + k // 2, 2, fill=PAL[3 + (k > 2)])

# lake: mirrored scene with ripples, darkened
img = c.flatten()
mir = img[60:124][::-1].copy()
ys = np.arange(len(mir))
for i in ys[::2]:                                                         # 1px ripples on every other row
    mir[i] = np.roll(mir[i], 1 if (i // 2) % 2 else -1, axis=0)
depth = np.linspace(0.75, 0.12, len(mir))[:, None, None] ** 1.3          # Fresnel: weaker reflection up close
mir[..., :3] = mir[..., :3] * depth + np.array([0.05, 0.07, 0.16]) * (1 - depth)
lake = np.zeros_like(img)
lake[124:124 + len(mir)] = mir[:180 - 124]
lake[124:, :, 3] = 1
water = c.layer()
water.rgba = lake
water.apply(pixelate(1, [PAL[i] for i in (0, 1, 2, 3, 4, 9, 10, 11)], dither="bayer", strength=0.3))  # water: cool sub-palette
rng = np.random.default_rng(SEED)
for y in range(127, 176, 3):                                              # sparse sun glints, wider near shore
    for _ in range(2):
        half = max(1, int((180 - y) / 8 * rng.uniform(0.4, 1.0)))
        x = 196 + int(rng.normal(0, 3 + (y - 124) / 6))
        water.line(x - half, y, x + half, y, stroke=PAL[7] if y < 150 else PAL[6], width=1)
water.line(234, 127, 242, 127, stroke=PAL[15], width=1)                   # window reflection
c.save(__file__[:-3] + ".png", scale=4)
Abstract generative image of flowing silk ribbons in teal, gold and coral
abstract.py
# Abstract generative: silk ribbons from a twice domain-warped field, flowing along an S-curve.
import numpy as np
from atelier import *

c = Canvas(1600, 1000, bg="#060914")
f = domain_warp(domain_warp(fbm(1.3, 4, seed=SEED), 0.45, 1.2, seed=SEED + 1), 0.1, 2.0, seed=SEED + 2)
hue = fbm(0.8, 3, seed=SEED + 5)                                     # slow colour drift
spine = Field(lambda x, y: np.exp(-((y - 0.52 - 0.22 * np.sin(x * 5.5 + 0.6)) / 0.32) ** 2))  # S-curve band
silk = (0.5 + 0.5 * np.sin(f * np.pi * 6)) ** 2                       # ribbon folds
pal = ["#0b1e3f", "#0f5e6e", "#2fa39a", "#e8c170", "#f27059", "#fde9d0"]
col = mix(hue.ramp(pal), "#fde9d0", silk * 0.2)
c.layer().fill(shade(f, color=col, strength=0.55, light=(110, 45), ambient=0.3, spec=0.15,
                     shininess=25), mask=spine)
threads = (1 - np.abs(np.sin(f * np.pi * 16))) ** 24                # fine silk threads
c.layer(blend="screen", opacity=0.55).fill("#fff2d8", mask=threads * spine ** 2)
c.apply(bloom(0.75, 16, 0.5), curves([(0.1, 0.06), (0.6, 0.64)]), vignette(0.4), grain(0.02, seed=SEED))
c.save(__file__[:-3] + ".png")
Ligne claire comic panel of a lighthouse on a rocky islet with a house, a sailboat and a speech balloon
comic.py
# Ligne-claire comic panel from a 3D scene: toon bands, ink outlines, halftone shadows, speech balloon.
from atelier import *
from atelier.stylize import toon, outlines, shadow_dots

s = Scene()
rock = sphere(1.0).scale((1.9, 0.55, 1.4)).displace(noise3(1.6, 2, seed=SEED + 1), 0.22)
rock = rock | sphere(0.8).scale((1.2, 0.6, 1.0)).move(1.5, -0.1, 0.6).displace(noise3(2, 2, seed=SEED + 2), 0.18)
isle = s.add(rock.move(0, -0.05, 0), Mat("#b9a58a", rough=0.9))                          # islet
sea = s.add(plane(0.0), Mat("#4f9bc9", rough=0.6))                                        # sea
tower = lathe([(0.42, 0.0), (0.36, 1.0), (0.3, 2.0), (0.27, 2.5)])
s.add(tower.move(-0.2, 0.3, -0.1), Mat("#f3eee2", rough=0.7))
for y0 in (0.55, 1.45):                                                                   # red bands
    s.add((tower.scale(1.015) & box(2, 0.42, 2).move(0, y0 + 0.21, 0)).move(-0.2, 0.3, -0.1), Mat("#d2412f"))
s.add(cylinder(0.42, 0.07).move(-0.2, 2.84, -0.1), Mat("#2f3a44"))                      # gallery
s.add(cylinder(0.2, 0.42).move(-0.2, 3.08, -0.1), Mat("#f6d55c", rough=0.4))             # lantern
s.add(cone(0.3, 0.32).move(-0.2, 3.29, -0.1), Mat("#d2412f"))                            # cap
house = box(0.9, 0.55, 0.7).move(0, 0.27, 0)
roof = extrude([(-0.55, 0), (0.55, 0), (0, 0.38)], 0.8).move(0, 0.55, 0)
s.add(house.rotate(y=-25).move(1.05, 0.3, 0.45), Mat("#efe3c8", rough=0.8))
s.add(roof.rotate(y=-25).move(1.05, 0.3, 0.45), Mat("#3f6f9a", rough=0.7))
s.add(box(0.16, 0.3, 0.02).rotate(y=-25).move(0.98, 0.45, 0.82), Mat("#6a4a32"))        # door
hull = ellipsoid(0.5, 0.2, 0.2).onion(0.02) - box(2, 1, 1).move(0, 0.5, 0)            # rowboat
s.add(hull.rotate(y=8).move(-2.3, 0.08, 1.4), Mat("#b8462f"))
s.add(extrude([(0, 0), (0.32, 0), (0, 0.6)], 0.02).rotate(y=8).move(-2.35, 0.1, 1.4), Mat("#f4f0e4"))  # sail
s.add(cylinder(0.012, 0.7).rotate(y=30).move(-2.35, 0.4, 1.4), Mat("#5a3f2a"))
s.sun((-1.2, 1.4, 1.0), power=3.2, soft=0.01)
s.sky("#8cc4e8", "#e9f2f5", "#4f9bc9", power=0.6)
s.camera((3.2, 1.9, 5.6), (0.25, 1.35, 0), fov=40)

c = Canvas(1200, 800)
c.fill(gradient(["#7cbde6", "#d6eef7"]))                                                 # flat comic sky
def cloud(x, y, k):                                                                      # puffy inked cloud
    puffs = [(-60, 0, 34), (-25, -22, 40), (20, -30, 46), (62, -8, 36), (20, 4, 38), (-30, 6, 30)]
    return union(*[circle(x + dx * k, y + dy * k, rr * k) for dx, dy, rr in puffs]) & rect(0, 0, 1200, y + 12 * k)
for x, y, k in [(860, 120, 1.25), (1110, 250, 0.8), (560, 300, 0.7)]:
    c.draw(cloud(x, y, k), fill="#ffffff", stroke="#16130f", width=2.5)
L = c.layer()
r = L.render(s, alpha=True)
L.apply(toon(r, bands=3, shadow=0.5, colors={sea: "#3f8fc4", isle: "#c9a878"}), shadow_dots(r, size=5, lo=0.3, color="#22303f", amount=0.8),
        outlines(r, width=2.6, inner=1.0, crease=0.6))
c.layer()
for i, (x, y) in enumerate([(120, 700), (1060, 720), (1040, 560), (180, 560), (760, 765)]):
    c.stroke([(x, y), (x + 22, y - 8), (x + 44, y), (x + 66, y - 8)], "ink", 4, "#16130f", seed=i)  # waves
for x, y in [(640, 120), (700, 95), (760, 140)]:                                          # gulls
    c.stroke([(x - 16, y - 6), (x - 7, y - 8), (x, y), (x + 7, y - 8), (x + 16, y - 6)], "ink", 3, "#16130f")
balloon = ellipse(300, 150, 230, 92) | polygon([(330, 225), (420, 300), (390, 222)])
c.draw(balloon, fill="white", stroke="#16130f", width=3)
c.text("THE LAMP IS LIT...", 300, 132, size=34, fill="#16130f", font="sans", weight="bold", align="center",
       valign="middle")
c.text("SOMEONE IS HOME!", 300, 176, size=34, fill="#16130f", font="sans", weight="bold", align="center",
       valign="middle")
c.rect(6, 6, 1188, 788, fill=None, stroke="#16130f", width=8)                           # panel border
c.save(__file__[:-3] + ".png")
Eroded mountain range lit by a low sun, reflected in a still lake
mountains.py
# Eroded alpine range at golden hour: wide valley lake, side light, physical sky and clouds, aerial perspective.
import numpy as np
from atelier import *
from atelier.nature import *

n = 1024
h = heightfield(n, "alpine", seed=SEED + 4, scale=3.0, warp=0.25, octaves=10)
zz, xx = np.mgrid[0:n, 0:n] / (n - 1)
rise = smoothstep(0.2, 0.7, zz)                                    # range rises with distance
sides = smoothstep(0.15, 0.48, np.abs(xx - 0.5)) * (1 - rise) * 0.3
h = h * (0.08 + 0.92 * rise) + sides + rise * 0.1
h = thermal(h, 30)
h, flow = erode(h, 2.5, seed=SEED, flow=True)
h = (h - h.min()) / (h.max() - h.min())

c = Canvas(1500, 1000)
v = c.landscape(h, flow=flow, size=22000, height=3600, water=0.07, cam=(11000, 300, 3200), fov=50,
              horizon=0.5, sun_elev=7, sun_az=-110, clouds=0.2, haze=1.6, snow=0.62, cloud_alt=(2800, 4200),
              waves=0.015, trees=0.7, aa=2, seed=SEED)
c.apply(bloom(0.9, 10, 0.15), grain(0.008, seed=SEED))
c.save(__file__[:-3] + ".png")
Editorial poster with the words VOYAGE TYPE, kerning pairs, a ring of text around an emblem and a justified paragraph
typography.py
# Editorial poster: kerned display type, text on curved paths, a justified rich-text column, title presets.
import math
from atelier import *

W, H = 1200, 1600
ink, paper, red, blue, gold = "#1b1a1f", "#f3ede1", "#d9412b", "#23466b", "#e8a93a"
M = 80
c = Canvas(W, H, bg=paper)
c.layer(blend="multiply", opacity=.18).fill(fabric(90) * .15 + .85)
c.layer()

# masthead
c.text("No. 12", M, 110, 26, weight="semibold", fill=ink, spacing=.08)
c.text("THE KERNING ISSUE", W - M, 110, 26, weight="semibold", fill=red, align="right", spacing=.22)
c.line(M, 132, W - M, 132, stroke=ink, width=3)

# display type: kerned (default) — AV, YA, To, Ty pairs tuck in
head = c.text("VOYAGE", M, 400, fit=W - 2 * M, weight="bold", fill=ink, spacing=-.015)
c.text("TYPE", M - 6, 640, fit=560, weight="bold", fill=red, spacing=-.02)

# kerning specimen: unkerned (grey ghost) vs kerned
sx, sy = 690, 470
c.text("kern=False", sx, sy - 6, 17, fill="#8a857b", spacing=.05)
c.text("kern=True", sx + 260, sy - 6, 17, fill=red, spacing=.05)
for i, pair in enumerate(["AV", "To", "Ty", "LT"]):
    y = sy + 52 + i * 46
    c.text(pair, sx, y, 44, weight="semibold", fill="#a9a398", kern=False)
    c.text(pair, sx + 260, y, 44, weight="semibold", fill=ink)
    c.line(sx + 120, y - 15, sx + 230, y - 15, stroke="#c9c1b2", width=1.5, dash=(4, 5))

# emblem: text around a circle
cx, cy, R = 930, 960, 170
c.circle(cx, cy, R, fill=gold)
c.circle(cx, cy, R - 46, fill=blue)
c.text_on_path("LETTERS IN ORBIT  ·  TYPE ON A PATH  ·  " * 2, circle(cx, cy, R - 34), 21, weight="semibold",
               fill=ink, lift=-9, fit=True)
c.text("Aa", cx, cy + 34, 110, weight="bold", fill=paper, align="center")

# wave with text riding on it
pts = [(M + i * 26, 1215 + 30 * math.sin(i * .21)) for i in range(0, 41)]
wave = spline(pts)
c.draw(wave, fill=None, stroke=blue, width=2.5)
c.text_on_path("Words can follow any curve you draw — kerned, spaced and upright along the tangent.", wave,
               25, fill=blue, lift=10, align="center", weight="semibold")

# justified rich-text column
body = ("**Good spacing is invisible.** Kerning adjusts the gap between specific pairs of letters, so that "
        "*AV*, *To* and *Ty* sit as close as their shapes allow while straight-sided pairs keep their "
        "natural rhythm. The values come straight from the font's own [#d9412b]GPOS pair tables[/], read by a "
        "tiny parser and cached per typeface.\n"
        "Paragraphs wrap word by word into a column; justified lines share the leftover space between words, "
        "and the last line of each paragraph stays flush left. Give the box a height and **fit=True**, and the "
        "size shrinks until everything fits.")
col = c.text_box(body, M, 780, 500, h=310, size=21, align="justify", line_height=1.45, fill=ink, fit=True)
c.text(f"set in Inter {col.size:.1f} px, justified", M, 780 + col.height + 26, 15, fill="#8a857b", spacing=.04)

# footer: title presets
c.line(M, 1300, W - M, 1300, stroke=ink, width=3)
c.title("SHADOW", M, 1400, 64, style="shadow", fill=red, color2=ink, depth=5)
c.title("INLINE", 440, 1400, 64, style="inline", fill=blue, color2=paper)
c.title("BLOCK", 790, 1400, 64, style="extrude", fill=gold, color2=ink, depth=7)
c.text("atelier · typography · kerning · text on path · paragraph layout", W / 2, 1520, 18, fill="#6f6a60",
       align="center", spacing=.06)
c.apply(grain(.02, 1.3, seed=SEED))
c.save(__file__[:-3] + ".png")
Crosshatched engraving of Doric columns, a sphere and cypress trees, drawn from a 3D model
engraving.py
# Copper-plate engraving of a 3D scene: hatches that follow the surfaces in perspective, ink contours.
from atelier import *
from atelier.stylize import render_hatch, outlines

s = Scene()
s.add(plane(0.0), Mat("#b0a898"))                                                         # ground
s.add(box(3.2, 0.12, 2.0).move(0, 0.06, -0.4) | box(2.8, 0.12, 1.6).move(0, 0.18, -0.4), Mat("#d8d0c0"))  # steps
for x in (-1.1, -0.37, 0.37, 1.1):                                                       # colonnade
    shaft = cylinder(0.14, 1.9).move(x, 1.19, -0.9)
    capital = round_box(0.42, 0.1, 0.42, 0.02).move(x, 2.19, -0.9)
    s.add(shaft | capital | round_box(0.4, 0.1, 0.4, 0.02).move(x, 0.29, -0.9), Mat("#ddd5c4"))
s.add(box(2.7, 0.18, 0.5).move(0, 2.33, -0.9), Mat("#d8d0c0"))                           # architrave
s.add(cylinder(0.14, 0.7).rotate(z=90).move(0.7, 0.38, 0.65), Mat("#d8d0c0"))            # fallen drum
s.add(sphere(0.32).move(-0.75, 0.56, 0.45), Mat("#d8d0c0"))                              # stone sphere
s.add(box(0.5, 0.2, 0.5).move(-0.75, 0.34, 0.45), Mat("#d8d0c0"))                        # its plinth
for x, z, h in [(-3.3, -3.0, 2.8), (-2.7, -3.8, 2.2), (2.4, -3.2, 2.5), (2.9, -3.9, 2.0)]:                  # cypresses
    s.add(ellipsoid(0.24, h / 2, 0.24).displace(noise3(5, 2, seed=int(x * 10)), 0.04).move(x, h / 2, z), Mat("#4a4a36"))
s.add(sphere(1).scale((9, 1.6, 3)).move(-4, -0.4, -14) | sphere(1).scale((8, 2.2, 3)).move(6, -0.6, -16),
      Mat("#a8a090"))                                                                      # distant hills
s.sun((-1.4, 1.2, 0.9), power=3.0, soft=0.02)
s.sky("#9ab", "#dde", "#887", power=0.25)
s.camera((1.6, 1.5, 5.0), (0, 1.05, -0.4), fov=44)

PAPER, INK = "#f1e9d6", "#2a1d14"
c = Canvas(1100, 850, bg=PAPER)
sky_tone = gradient(["#9a9a9a", "#e8e8e8"]) + smoothstep(0.45, 0.6, clouds(1.6, 0.45, seed=SEED + 2).scale(2.5, 0.7))
c.hatch(None, angle=0, spacing=4.5, width=1.0, color=INK, tone=sky_tone.clip(), wobble=0.25)  # engraved sky
L = c.layer()
r = L.render(s, alpha=True)
L.clear()
L.fill(PAPER, mask=r.alpha)
L.apply(render_hatch(r, s, spacing=4.5, width=1.15, layers=3, color=INK, lo=0.0, wobble=0.2),
        outlines(r, width=2.0, inner=0.9, color=INK, crease=0.5, depth=0.05, rough=0.5))
c.layer(blend="overlay", opacity=0.5).fill(paper())                                   # paper tooth
c.layer()
c.draw(rect(0, 0, 1100, 850) - rect(45, 45, 1010, 690), fill=PAPER)                    # plate margin
c.rect(45, 45, 1010, 690, fill=None, stroke=INK, width=2.5)
c.rect(36, 36, 1028, 708, fill=None, stroke=INK, width=1)
c.text("RUINS OF A DORIC TEMPLE", 550, 785, size=30, fill=INK, font="serif", align="center", spacing=0.18)
c.text("Plate IV  -  drawn from a 3D model", 550, 818, size=15, fill=INK, font="serif", italic=True, align="center")
c.save(__file__[:-3] + ".png")
Flat illustration of a park with people walking a dog, a child with a red balloon, a jogger and a crowd
people.py
# Flat-illustration park scene: posed characters, a dog, a bench, and a perspective crowd in the distance.
from atelier import *
from atelier.figure import *

W, H = 1600, 1000
HZ = 520                                                     # horizon (eye level)
c = Canvas(W, H, bg=gradient(["#9ccbe0", "#d9ecee", "#f5eedc"], angle=90))
c.circle(1270, 170, 70, fill="#fff6dc")
for i, (x, y, s) in enumerate([(260, 140, 1), (760, 95, .7), (1120, 250, .8)]):      # flat clouds
    c.draw(union(ellipse(x, y, 90 * s, 28 * s), ellipse(x - 40 * s, y - 18 * s, 50 * s, 36 * s),
                 ellipse(x + 35 * s, y - 24 * s, 58 * s, 40 * s)), fill="#ffffff", opacity=.85)
# distant city + hills
for i, (x, w, h) in enumerate([(40, 70, 120), (115, 55, 170), (175, 90, 95), (1180, 60, 150), (1245, 80, 210),
                               (1330, 50, 130), (1385, 95, 175), (1485, 70, 110)]):
    c.rect(x, HZ - h, w, h + 5, fill="#b9cdd6")
c.fill("#9cc28f", mask=svg(f"M0 {HZ - 30} C 300 {HZ - 80} 600 {HZ - 10} 900 {HZ - 45} S 1400 {HZ - 70} 1600 {HZ - 35} V {HZ + 20} H 0 Z"))
c.rect(0, HZ, W, H - HZ, fill=gradient(["#8fbf72", "#6fa957"], angle=90))
# path toward the vanishing point
c.polygon([(760, HZ), (840, HZ), (1250, H), (380, H)], fill=gradient(["#e8dcc0", "#d8c7a0"], angle=90))
c.scatter("grass", 260, region=rect(0, HZ + 120, W, H - HZ - 120) - polygon([(760, HZ), (840, HZ), (1290, H), (340, H)]),
          size=(8, 18), seed=SEED + 2)

def tree(x, y, s, col):
    c.draw(rect(x - 9 * s, y - 120 * s, 18 * s, 125 * s, 6 * s), fill="#6b4a33")
    c.draw(union(circle(x, y - 175 * s, 75 * s), circle(x - 55 * s, y - 130 * s, 55 * s),
                 circle(x + 55 * s, y - 135 * s, 58 * s)), fill=col)
    c.draw(circle(x + 22 * s, y - 200 * s, 30 * s), fill="#ffffff", opacity=.12)

for x, y, s, col in [(120, HZ + 40, .7, "#4f8a46"), (1480, HZ + 45, .75, "#3f7a3c"), (560, HZ + 12, .35, "#4f8a46"),
                     (1050, HZ + 18, .4, "#3f7a3c"), (1290, HZ + 25, .5, "#4f8a46")]:
    tree(x, y, s, col)
# distant crowd along the path, fading into the haze
c.crowd(26, (650, HZ + 18, 960, HZ + 120), horizon=HZ - 6, height=70, seed=SEED + 5, dogs=.08,
        tint=("#cfe2e6", .45), spacing=.45)

def shadow(x, y, w):
    c.ellipse(x, y, w, w * .16, fill="#2c4a2a", opacity=.22)

# bench with a reader
c.rect(150, 800, 300, 18, 6, fill="#8a5a3b"); c.rect(150, 760, 300, 14, 6, fill="#8a5a3b")
c.rect(170, 815, 14, 90, fill="#4a3a30"); c.rect(416, 815, 14, 90, fill="#4a3a30")
shadow(300, 910, 150)
c.figure(320, 910, 300, pose="sit", view=-80, seed=11, outfit="long pants", hair="bun", skin="tan",
         colors={"top": "#e9c46a", "bottom": "#264653", "hair": "#2b1d14"}, head=(8, -20, 4))
# a couple walking a dog toward us
shadow(700, 905, 80); shadow(820, 915, 80)
c.figure(690, 905, 340, pose="walk", view=-55, phase=.22, seed=21, outfit="long pants", hair="short",
         skin="brown", colors={"top": "#e76f51", "bottom": "#2b2d42", "hair": "#1d1a1a"})
walker = dict(pose="walk", view=-55, phase=.72, seed=22)
c.figure(815, 915, 320, outfit="tee skirt", hair="long", skin="light",
         colors={"top": "#f4f1ea", "bottom": "#2a9d8f", "hair": "#8c3b1e"}, **walker)
hand = max(figure_joints(815, 915, 320, **walker).values(), key=lambda q: q[0] - abs(q[1] - 780))
dog = dict(kind="dog", pose="walk", phase=.3, seed=4)
collar = figure_joints(990, 935, 85, **dog)["neck_top"]
c.spline([hand, ((hand[0] + collar[0]) / 2, max(hand[1], collar[1]) + 25), collar], stroke="#3a2a22", width=2)
shadow(990, 935, 60)
c.figure(990, 935, 85, colors={"body": "#c8955a"}, **dog)
# kid jumping with a balloon, parent waving
shadow(1150, 925, 50)
kid = dict(pose="cheer", body="child", seed=31, arm_l=(10, 140, 0, 20))
c.figure(1150, 925, 220, outfit="tee shorts", hair="curly", skin="dark",
         colors={"top": "#f3a712", "bottom": "#3d405b"}, **kid)
kh = figure_joints(1150, 925, 220, **kid)["hand_l"]
c.spline([kh, (kh[0] + 12, kh[1] - 60), (1226, 525)], stroke="#555", width=1.5)
c.ellipse(1225, 485, 34, 40, fill="#e63946"); c.circle(1213, 472, 8, fill="#ffffff", opacity=.35)
shadow(1370, 935, 80)
c.figure(1370, 935, 330, pose="wave", view=-25, seed=32, outfit="long pants", hair="ponytail", skin="olive",
         colors={"top": "#2e86ab", "bottom": "#6b705c", "hair": "#3b2618"})
# mid-ground: jogger and someone on the phone
shadow(500, 690, 40)
c.figure(500, 690, 175, pose="run", phase=.3, seed=41, outfit="tank shorts", hair="buzz", skin="medium",
         colors={"top": "#d62828", "bottom": "#22223b"})
shadow(1060, 700, 40)
c.figure(1060, 700, 170, pose="phone", view="3q", seed=42, outfit="coat pants", body="elder",
         colors={"top": "#6d597a", "bottom": "#455a64"}, hair="grey")
c.apply(grain(.015, 1.2, seed=SEED))
c.save(__file__[:-3] + ".png")
Sunny meadow framed by an oak, a birch and pines, with clouds and wild flowers
forest.py
# Forest edge on a summer morning: physical sky, hazy wooded ridges, meadow, oaks, birches and pines.
import numpy as np
from atelier import *
from atelier.nature import *

W, H = 1500, 1000
rng = np.random.default_rng(SEED)
LIGHT = (150, 28)                                   # 2D light: from the upper left
SUN = dict(sun_elev=11, sun_az=-38)


def ground(y0, amp, seed, k=3):
    """A rolling ground line y(x) in canvas px."""
    r = np.random.default_rng(seed)
    ph, fr = r.uniform(0, 6.3, k), r.uniform(1.0, 3.5, k)
    return lambda x: y0 + amp * sum(np.sin(x / W * f * np.pi + p) / (i + 1) for i, (p, f) in enumerate(zip(ph, fr)))


c = Canvas(W, H)
c.sky(**SUN, horizon=0.56, fov=50, clouds=0.3, haze=1.3, seed=SEED + 7)
sky_haze = "#c9cfd2"

# far wooded ridges: each band = hazy fill + a dense row of small conifers along its crest
bands = [(540, 34, 0.5, 34), (585, 30, 0.38, 44), (635, 26, 0.26, 56)]
for i, (y0, amp, hz, th) in enumerate(bands):
    g = ground(y0, amp, SEED + 10 + i)
    xs = np.arange(0, W + 1, 4.0)
    c.layer()
    col = np.array(color("#22382a")[:3]) * (1 - hz) + np.array(color(sky_haze)[:3]) * hz
    c.polygon([(0, H)] + [(x, g(x) + th * 0.35) for x in xs] + [(W, H)], fill=tuple(col))
    for x in np.sort(rng.uniform(-20, W + 20, int(W / th * 1.6))):
        c.tree(x, g(x) + th * 0.45, th * rng.uniform(0.8, 1.4), "pine", seed=int(rng.integers(1e6)),
               light=LIGHT, haze=hz, haze_color=sky_haze, shadow=0, leaves=0.7)

# meadow: lit gold-green with darker undulations, soft shadow under the near ridge
c.layer()
mg = ground(690, 14, SEED + 20)
meadow = polygon([(0, H)] + [(x, mg(x)) for x in np.arange(0, W + 1, 5.0)] + [(W, H)])
c.draw(meadow, fill=gradient([(0, "#9aa457"), (0.35, "#7f9444"), (1, "#45612a")], angle=90).within(meadow))
c.fill("#566f2e", mask=meadow * smoothstep(0.45, 0.7, fbm(5, seed=SEED + 3)).scale(1, 0.3) * 0.5)
c.fill("#d4c27a", mask=meadow * (left(0.45, 0.3) * band(0.68, 0.8, 0.05) * 0.25))      # warm light on the left

# mid-distance trees along the meadow edge, back to front
mid = [(60, "pine", 190), (420, "oak", 150), (520, "pine", 210), (610, "birch", 170), (700, "oak", 140),
       (880, "pine", 230), (980, "oak", 160), (1080, "birch", 150), (1250, "pine", 200), (1360, "oak", 150)]
c.layer()
for x, k, hgt in mid:
    c.tree(x, mg(x) + 6, hgt, k, seed=int(x), light=LIGHT, haze=0.22, haze_color=sky_haze, shadow=0.3)
for x in rng.uniform(0, W, 40):
    c.grass(x, mg(x) + 8, 50, 12, n=18, seed=int(x * 7), light=LIGHT, haze=0.2, haze_color=sky_haze, dry=0.4)
c.scatter("grass", 2500, region=meadow * bottom(0.72, 0.05), size=(6, 16), color=["#5d7a34", "#7b9142", "#a3a65a",
          "#4c662b"], color_jitter=0.15, seed=SEED + 4)

# foreground: big oak (left), birch pair (right), undergrowth
c.layer()
fy = 965
c.draw(polygon([(0, H), (0, 900), (300, 880), (700, 915), (1100, 895), (1500, 870), (W, H)]),
       fill=gradient(["#4b6427", "#2a3d19"], angle=90).within(rect(0, 860, W, 140)))
c.tree(230, fy, 700, "oak", seed=SEED + 5, light=LIGHT, shadow=0.45, width=1.1)
c.tree(1180, fy - 10, 700, "birch", seed=SEED + 6, light=LIGHT, shadow=0.35)
c.tree(1320, fy + 5, 600, "birch", seed=SEED + 8, light=LIGHT, shadow=0.35)
c.tree(1460, fy + 15, 760, "pine", seed=SEED + 9, light=LIGHT, shadow=0.35)
for i, x in enumerate(np.linspace(-20, W + 20, 46)):
    c.grass(x + rng.normal(0, 12), 1000 - rng.uniform(0, 70), 90, rng.uniform(30, 60), n=40, seed=i,
            light=LIGHT, dry=0.15)
for x in (520, 640, 1020):
    c.fern(x, 990, rng.uniform(80, 120), seed=int(x), light=LIGHT)
for i in range(40):
    x = rng.uniform(380, 1100)
    c.flower(x, rng.uniform(930, 995), rng.uniform(22, 38), ["daisy", "poppy", "buttercup", "bell"][i % 4],
             seed=i, light=LIGHT)

# morning light: warm glow from the sun side, gentle haze, then grade
c.layer(blend="screen", opacity=0.35).fill(gradient(["#ffcf8a", "#000000"], kind="radial", center=(0.05, 0.45),
                                                    radius=0.9))
c.apply(bloom(0.8, 14, 0.25), curves([(0.1, 0.08), (0.85, 0.9)]), vignette(0.28), grain(0.012, seed=SEED))
c.save(__file__[:-3] + ".png")
Flat vector dusk: a lone tree on a hill, violet and peach banded sky, sun rays, fireflies and petals over a lake
dusk.py
# Flat vector dusk: banded sky, layered hills with rim light, lone tree, lake reflection, fireflies and petals.
import numpy as np
from atelier import *

W, H = 900, 1600
HZ = 1180                                     # shoreline (px)
SUN = (300, 880)
c = Canvas(W, H)

# sky: purple -> peach, with flat horizontal bands
c.fill(gradient([(0, "#2a2350"), (0.18, "#4b3f86"), (0.36, "#7a5fa8"), (0.48, "#e0795f"),
                 (0.56, "#ffb070"), (0.62, "#ffd9a0"), (0.74, "#c9a6d6")]))
bands = c.layer(blend="soft_light", opacity=0.6)
rng = np.random.default_rng(SEED)
for y in np.cumsum(rng.uniform(40, 110, 14)):
    t = rng.uniform(0.3, 1)
    bands.rect(-20, y, W + 40, rng.uniform(14, 40), r=20,
               fill="#ffffff" if rng.random() > 0.45 else "#20184a", opacity=0.35 * t)
c.layer(blend="screen").fill("#ffd59a", mask=disc(SUN[0] / W, SUN[1] / H, 0.08, soft=0.35))
c.layer(blend="screen", opacity=0.5).fill("#ff9d6e", mask=disc(SUN[0] / W, SUN[1] / H, 0.25, soft=0.8))

# distant mountains, hazy lavender
far = c.layer()
far.path("M -20 980 C 100 960 200 1000 300 1010 C 420 930 500 900 560 880 C 620 860 660 900 720 870 C 790 835 860 850 920 830 L 920 1200 L -20 1200 Z",
         fill="#8f8fcf")
far.path("M -20 1080 C 150 1050 280 1090 380 1060 C 480 1010 560 990 640 975 C 720 960 820 985 920 960 L 920 1200 L -20 1200 Z", fill="#a3a8de")

# distant tree silhouettes on the right
def blob_tree(L, x, y, s, col):
    L.rect(x - s * 0.04, y - s * 0.55, s * 0.08, s * 0.55, fill=col)
    L.draw(union(ellipse(x, y - s * 0.7, s * 0.42, s * 0.22), ellipse(x - s * 0.3, y - s * 0.6, s * 0.25, s * 0.16),
                 ellipse(x + s * 0.32, y - s * 0.62, s * 0.26, s * 0.15), ellipse(x + s * 0.05, y - s * 0.86, s * 0.25, s * 0.15)),
           fill=col)

trees = c.layer()
for x, y, s in [(520, 1100, 150), (640, 1090, 120), (790, 1080, 190), (880, 1090, 130)]:
    blob_tree(trees, x, y, s, "#7d82c4")

# rolling hills, back to front, each with a warm rim on its crest
hills = [("M -20 1000 C 120 940 280 960 420 1060 C 520 1120 600 1140 700 1150 L 700 1220 L -20 1220 Z", "#3b3a78"),
         ("M 330 1150 C 450 1090 600 1090 760 1120 C 840 1135 900 1140 920 1140 L 920 1220 L 330 1220 Z", "#4d4f92"),
         ("M -20 1080 C 160 1060 300 1120 380 1180 L -20 1200 Z", "#2a2a5e")]
for d, col in hills:
    L = c.layer()
    s = svg(d)
    L.draw(s, fill=col)
    L.draw(s.outline(4), fill="#ffb87a", opacity=0.55, feather=1.5)
c.layer().rect(0, HZ - 14, W, 14, fill="#2a2a5e")

# lone tree: dark trunk + layered canopy, rim-lit on the sun side
tree = c.layer()
trunk = svg("M 80 1080 C 95 960 70 860 120 760 C 140 720 170 690 210 660 L 225 675 C 180 720 150 780 145 860 "
            "C 140 950 150 1020 150 1080 Z")
branch = svg("M 150 780 C 220 720 300 700 380 690 L 382 702 C 300 715 230 740 160 800 Z") | \
         svg("M 120 820 C 70 760 40 720 20 640 L 32 636 C 55 710 90 760 130 805 Z")
tree.draw(trunk | branch, fill="#1c1a3c")
canopy = union(ellipse(170, 610, 190, 70), ellipse(70, 560, 120, 55), ellipse(260, 560, 150, 50),
               ellipse(420, 640, 130, 32), ellipse(520, 665, 70, 18), ellipse(140, 500, 110, 45))
tree.draw(canopy, fill="#1f1d44")
tree.draw(canopy & canopy.move(-18, -14), fill="#2b2958")
tree.draw(canopy.outline(3), fill="#f6b27c", opacity=0.5)

# lake: mirrored sky/land, darkened, with flat ripple strokes
img = c.flatten()
hz = int(HZ * c.s)
h = img.shape[0] - hz
src = img[hz - h:hz][::-1].copy()
fade = np.linspace(0.85, 0.45, h)[:, None, None]
src[..., :3] = src[..., :3] * fade + np.array([0.12, 0.12, 0.3]) * (1 - fade)
src[..., 3] = 1
lake = c.layer()
buf = np.zeros_like(img)
buf[hz:] = src
lake.rgba = buf
rip = c.layer(opacity=0.8)
h = H - HZ
for k in range(45):
    y = HZ + 8 + (k / 45) ** 1.6 * h
    x = rng.uniform(-100, W)
    w = rng.uniform(60, 260) * (0.5 + (y - HZ) / h)
    near_sun = abs(x + w / 2 - SUN[0]) < 200
    rip.line(x, y, x + w, y, stroke="#ffcf9a" if near_sun else "#8a86d0", width=1.5 + 2.5 * (y - HZ) / h)
c.layer().rect(0, HZ - 2, W, 4, fill="#ffc38a", opacity=0.6)

# fireflies and falling petals
ff = c.layer()
ff.scatter("dot", 120, region=band(0.05, 0.95), size=(5, 11), color=["#fff1b0", "#ffd36e"], seed=SEED + 1)
ff.apply(glow(18, 1.6, "#ffd36e"))
c.layer().scatter("petal", 70, region=band(0.0, 0.9), size=(14, 24), color=["#f5b8c8", "#ffd0dc", "#e89ab8"],
                  rot=(0, 360), seed=SEED + 2)

c.layer().sparkles(60, region=zone(0, HZ / H, 1, 1) * disc(SUN[0] / W, 0.85, 0.25, soft=0.6), size=(6, 18), seed=SEED + 5)
c.apply(god_rays(*SUN, rays=16, strength=0.5), lens_flare(*SUN, strength=0.5), bloom(0.7, 18, 0.6), grain(0.015), vignette(0.25))
c.save(__file__[:-3] + ".png")
Anime-style painted landscape: big cel-shaded cumulus over snowy mountains, round trees and a bright green meadow
anime_sky.py
# Anime summer sky: cel-shaded cumulus over snowy peaks and a bright meadow.
import numpy as np
from atelier import *

W, H = 1600, 1000
c = Canvas(W, H)
c.fill(gradient([(0, "#1f5fd0"), (0.35, "#3f86e6"), (0.62, "#8fc6f5"), (0.75, "#cfe8fb")]))

# clouds: big tower left-centre, smaller ones trailing, faint far row near the horizon
sky = c.layer()
for x, y, w, sd in [(300, 640, 300, 11), (1350, 600, 280, 12), (800, 650, 220, 13)]:
    sky.anime_cloud(x, y, w, h=w * 0.35, palette=("#b8cbe8", "#dce8f7", "#ffffff", "#ffffff"), wisps=0.2, seed=sd)
sky.anime_cloud(1180, 330, 620, h=330, light=(130, 40), seed=4)
sky.anime_cloud(420, 330, 420, h=200, light=(130, 40), seed=7)
sky.anime_cloud(1480, 250, 260, h=120, light=(130, 40), seed=9)

# mountains: far pale range, then the main snowy range (cel-shaded facets radiating from the summits)
rng = np.random.default_rng(SEED)
c.layer().anime_mountains(760, 380, peaks=5, light=(150, 30), snow=0.3, haze=0.55, seed=5,
                          palette=("#7d9fd2", "#93b3e0", "#a9c5ea", "#f6f9ff", "#cddbf0", "#d6e8f8"))
c.layer().anime_mountains(790, 400, peaks=3, x0=150, x1=1250, light=(150, 30), snow=0.4, seed=8)
c.layer(opacity=0.4).fill("#d4e7f8", mask=band(0.66, 0.82, soft=0.1))     # aerial haze at the foot

# rolling meadows, far to near: flat cel bands, a lighter lit strip on each crest
fields = [(0.70, 0.025, "#8cc95e", "#b5e07a", 21), (0.76, 0.04, "#6db74a", "#9ad65e", 22), (0.86, 0.06, "#4c9a3a", "#7cc64d", 23)]
for y, amp, dark, light, sd in fields:
    crest = fbm(1.2, 2, seed=sd).ridge_line(y, amp)
    L = c.layer()
    L.fill_below(crest, light)
    L2 = c.layer()
    L2.fill_below(fbm(1.2, 2, seed=sd).ridge_line(y + 0.025, amp * 0.8), dark)
for x, y, hgt, kind, sd in [(110, 770, 260, "round", 31), (270, 778, 180, "tall", 32), (1400, 768, 270, "round", 33),
                            (1545, 780, 170, "round", 34), (700, 745, 70, "round", 35), (760, 748, 55, "tall", 36)]:
    c.layer().anime_tree(x, y, hgt, kind=kind, light=(130, 40), seed=sd)

# foreground: bright grass tufts and small flowers
fg = c.layer()
for k in range(26):
    gx = k * 64 + rng.uniform(-20, 20)
    fg.grass(gx, 1005, 120, rng.uniform(50, 90), 70, color=["#3f8f34", "#6cbf45", "#a6e06a"], seed=SEED + k, bend=0.4)
fg.scatter("dot", 260, region=band(0.88, 1.0), size=(3, 7), color=["#ffffff", "#fff3a0", "#ffd84a"], seed=SEED + 3)

c.apply(curves([(0, 0.03), (0.5, 0.52), (1, 1)]), saturation(1.08), grain(0.01), vignette(0.15))
c.save(__file__[:-3] + ".png")

brush · paint_like

From colour study to oil on canvas.

The agent builds a flat colour study out of gradients, noise and shapes, then paint_like repaints it with thousands of oil strokes, coarse to fine, following the image's own edges. Impasto catches a virtual light. Drag the handle.

c = Canvas(1500, 1000, bg="#e9dfcc")
c.paint_like(ref, "oil", sizes=(64, 32, 16, 9))
c.cur.relief = .6          # impasto lighting
Oil painting rendered with paint_like
Flat colour study used as the reference
referenceoil · 4 passes

fx · 30+ effects

Same pixels.
Eight moods.

Bloom, halftone, dithering, gradient maps, lens distortion, film grain, tone mapping, depth of field. Effects chain in one call. Pick one, or let it cycle.

Lighthouse illustration with the original effect chainLighthouse illustration with the bloom effect chainLighthouse illustration with the halftone effect chainLighthouse illustration with the pixel effect chainLighthouse illustration with the duotone effect chainLighthouse illustration with the film effect chainLighthouse illustration with the noir effect chainLighthouse illustration with the night effect chain
lighthouse.py
c.save("lighthouse.png")
c.apply(exposure(.2), bloom(.45, 46, 1.8, tint=GOLD),
        glow(14, .7, "#ffd59a"))
c.apply(halftone(9, 22, cmyk=True))
c.apply(pixelate(6, "retro16", dither="bayer", strength=.5))
c.apply(gradient_map(["#14213d", "#e8553a", "#f6e3c4"]))
c.apply(chromatic_aberration(3), lens_distortion(.12),
        grain(.06, 1.6), vignette(.6))
c.apply(saturation(0), contrast(.35), grain(.05, 1.2),
        vignette(.55))
c.apply(hue_sat(-30, .6, -.15), curves([(0, 0), (.5, .36), (1, .85)]),
        color_balance(shadows=(-.04, 0, .08)))

One flat illustration, rendered once. Every variant is the same array passed through a different chain.

What's inside

Twelve modules, one canvas.

Everything is computed on the CPU with numpy, skia, OpenCV and scipy. Each icon below was drawn with the module it stands for.

  • core module icon, drawn with atelier.core

    atelier.coreCanvas and layers

    Layers, 17 blend modes, masks, image import, PNG / JPG / WebP export and real low-res previews.

    c.layer(blend="screen")
  • field module icon, drawn with atelier.field

    atelier.fieldProcedural fields

    Lazy, composable noise: perlin, simplex, fbm, ridged, worley, domain warp, wood, marble, clouds, 2.5D shading.

    domain_warp(fbm(1.3), .45)
  • vector module icon, drawn with atelier.vector

    atelier.vectorVector and type

    Skia paths and SVG, booleans, dashes, embedded fonts with real kerning, text on a path, justified text boxes, title presets.

    c.text_on_path(txt, ring)
  • brush module icon, drawn with atelier.brush

    atelier.brushBrush engine

    Round, ink, chalk, bristle, oil with impasto, watercolour with glazing. Scatter, hatching, pigment mixing.

    c.stroke(pts, "oil")
  • fx module icon, drawn with atelier.fx

    atelier.fxEffects and grading

    Blur, bloom, glow, shadows, curves, AgX tone mapping, grain, halftone, pixelate with dithering, depth of field.

    c.apply(bloom(.8), grain())
  • sdf module icon, drawn with atelier.sdf

    atelier.sdf3D modelling

    Signed-distance primitives and smooth booleans, materials with bump, object-space textures and wear, a fast raymarcher.

    torus(.6, .16).twist(2)
  • pt module icon, drawn with atelier.pt

    atelier.ptPath tracer

    Global illumination, glass and liquids that really refract, adaptive anti-aliasing and a denoiser guided by albedo and normals.

    c.pathtrace(s, spp=16)
  • stylize module icon, drawn with atelier.stylize

    atelier.stylize3D to illustration

    Toon shading, ink outlines, halftone shadows and hatching that follows surfaces. Kuwahara, XDoG, linocut, stained glass.

    toon(r) · outlines(r)
  • nature module icon, drawn with atelier.nature

    atelier.natureLandscapes

    Eroded terrain, a physical sky with raymarched clouds, lakes and seas with real waves, trees, grass and flowers. Plus cel-shaded anime clouds, trees and peaks.

    c.landscape(terrain(512))
  • figure module icon, drawn with atelier.figure

    atelier.figurePeople and animals

    Posable figures with outfits, hair and body types, cats, dogs and horses, perspective crowds, a 3D mannequin.

    c.figure(x, y, h, "wave")
  • inspect module icon, drawn with atelier.inspect

    atelier.inspectSelf-review

    Contact sheets with value studies, clipping maps and auto zooms on aliasing, banding and noise. A/B diffs. Metrics.

    atelier inspect s.py
  • cli module icon, drawn with atelier.cli

    atelier.cliCommand line

    Render, preview at 512 px, crop at full resolution, inspect, compare, change the seed. Errors come back as one line.

    atelier render s.py --preview
  • 15 KB

    SKILL.mdThe cheat sheet

    The whole API, the render → look → fix loop, recipes per style and known pitfalls, in one file an agent reads once.

    read SKILL.md ↗

Built for agents

Made for a model that can see.

  • Previews are real rendersA 512 px preview is the same picture, only softer, so what the model sees is what it gets.
  • It reviews its own workatelier inspect makes one contact sheet: value study, clipping map, histogram and automatic zooms on aliasing, banding and noise.
  • Zoom where it matters--crop renders one region at full resolution to check a reflection or an edge.
  • Errors fit in one linefile:line, the offending source and the exception. No traceback to wade through.
  • Deterministic by defaultEvery random call takes a seed. --seed N gives a new variation, same seed gives the same pixels.
  • Nothing to provisionpip install. No GPU, no model weights, no API key, no network at render time.
claude · ~/scenes
  1. ◆ writes still.py · 52 lines
  2. $ atelier render still.py --preview
  3. preview 512px -> still_prev.jpg (1.9s)
  4. ◆ looks: the lemon floats, the key light is too flat
  5. $ atelier inspect still.py
  6. sheet -> still_sheet.jpg · ! shadows crushed 2.2% · zoom 1 aliasing --crop 735,411,100,100
  7. $ atelier render still.py --crop 600,420,300,200
  8. crop 600,420,300,200 -> still_crop.jpg (3.2s)
  9. ◆ fixes: lemon.move(0, .075, .28) · s.area(power=48)
  10. $ atelier render still.py --preview
  11. error: still.py:31: s.add(lemmon.move(...), Mat(...))
  12. NameError: name 'lemmon' is not defined
  13. ◆ one line, one fix: lemmon -> lemon
  14. $ atelier render still.py --scale 2
  15. final 2400x1600 -> still.png (38.4s)

Install

Give your agent an atelier.

One pip package for the renderer, one skill folder for the know-how: the cheat sheet and a style guide per look.

Paste this to your agent. It installs the package, reads the cheat sheet and follows it, fetching the style guides it needs.

prompt
pip install git+https://github.com/Wexop/atelier, then read https://raw.githubusercontent.com/Wexop/atelier/main/SKILL.md and follow it.
Python ≥ 3.11numpypillowopencv-python-headlessscipyskia-pythonMIT licence