lib.graphics

  1from typing import Literal, Tuple, TypeAlias
  2from math import ceil, exp, sqrt
  3import random
  4import drawBot
  5from drawBot import _drawBotDrawingTool as drawBot
  6from drawBot import drawBotDrawingTools
  7from lib import layout, color
  8from icecream import ic
  9
 10BlendMode: TypeAlias = Literal[
 11    "normal",
 12    "multiply",
 13    "screen",
 14    "overlay",
 15    "darken",
 16    "lighten",
 17    "colorDodge",
 18    "colorBurn",
 19    "softLight",
 20    "hardLight",
 21    "difference",
 22    "exclusion",
 23    "hue",
 24    "saturation",
 25    "color",
 26    "luminosity",
 27    "clear",
 28    "copy",
 29    "sourceIn",
 30    "sourceOut",
 31    "sourceAtop",
 32    "destinationOver",
 33    "destinationIn",
 34    "destinationOut",
 35    "destinationAtop",
 36    "xOR",
 37    "plusDarker",
 38    "plusLighter",
 39]
 40"""Supported blend modes.
 41
 42- taken from https://www.drawbot.com/content/color.html#drawBot.blendMode"""
 43
 44
 45LineCap = Literal["butt", "round"]
 46"""Omitted `square` (rarely used)."""
 47
 48
 49def setBlendMode(mode: BlendMode):
 50    """Set the blend mode for drawing operations.
 51
 52    Args:
 53        mode: The blend mode to use.
 54    """
 55    drawBot.blendMode(mode)
 56
 57
 58# TODO: Merge with app.drawLogo() that doesnt have shade param
 59# - Add black/color mode
 60# - Rename `density` to `darkness`
 61# - Rename `scale` to `upsample`
 62def drawLogo(
 63    size=200,
 64    lineWidth=10,
 65    sharpness=0.95,
 66    shade: tuple | color.HSL | color.CMYK = (0,),
 67    density=0.5,
 68    scale=2,
 69) -> drawBotDrawingTools.ImageObject:
 70    """
 71    Draw a logo image with customizable parameters.
 72
 73    Args:
 74        size: The base size of the logo.
 75        lineWidth: The width of the lines in the logo.
 76        sharpness: The sharpness of the lines.
 77        shade: The fill color or shade.
 78        density: Increase float to darken.
 79        scale: The upsample scale factor.
 80
 81    Returns:
 82        ImageObject: The generated logo image.
 83    """
 84    imgComet = drawBot.ImageObject()
 85
 86    # Image 1: Comet
 87    with imgComet:
 88        size *= scale
 89        drawBot.size(size, size)
 90        center = (size / 2,) * 2
 91        drawBot.radialGradient(
 92            center,
 93            center,
 94            colors=[(1 - density,) * 3, (1,) * 3],
 95            locations=[0, 1],
 96            startRadius=size / 4,
 97            endRadius=size / 2,
 98        )
 99        drawBot.rect(0, 0, size, size)
100
101    imgComet.lineScreen(width=lineWidth * scale, sharpness=sharpness)
102    imgComet.colorInvert()
103
104    # Image 2: Solid
105    imgSolid = drawBot.ImageObject()
106    with imgSolid:
107        drawBot.size(*imgComet.size())
108        if isinstance(shade, (color.HSL, color.CMYK)):
109            shade.setFill()
110        else:
111            drawBot.fill(*shade)
112        drawBot.rect(0, 0, *imgComet.size())
113
114    imgSolid.blendWithMask(maskImage=imgComet)
115    imgSolid.lanczosScaleTransform(scale=1 / scale)
116
117    return imgSolid
118
119
120def renderClouds(
121    dimensions: tuple, zoom=150, dot=10, scale=1, brightness=0, contrast=1.5
122) -> drawBotDrawingTools.ImageObject:
123    """Render a cloud-like image with random dot patterns.
124
125    Args:
126        dimensions: The width and height of the output image.
127        zoom: The zoom factor for the dot pattern.
128        dot: The size of the dots.
129        scale: The scale factor for the inner image.
130        brightness: Brightness adjustment.
131        contrast: Contrast adjustment.
132
133    Returns:
134        ImageObject: The generated cloud image.
135    """
136    imgOuter = drawBot.ImageObject()
137    w, h = layout.toDimensions(dimensions)
138
139    with imgOuter:
140        drawBot.size(w, h)
141        drawBot.fill(0.9)
142        drawBot.rect(0, 0, w, h)
143
144        imgInner = drawBot.ImageObject()
145        with imgInner:
146            drawBot.size(*[dim * scale for dim in [w, h]])
147        imgInner.randomGenerator([ceil(dim / zoom) for dim in imgInner.size()])
148        imgInner.lanczosScaleTransform(zoom)
149
150        shiftPos = shiftWithin(dimensions, imgInner)
151        drawBot.image(imgInner, shiftPos)
152
153    imgOuter.colorControls(brightness=brightness, contrast=contrast)
154    imgOuter.dotScreen(width=zoom / dot, angle=45, sharpness=1)
155    imgOuter.colorInvert()
156
157    return imgOuter
158
159
160def shiftWithin(
161    parent: tuple, child: drawBotDrawingTools.ImageObject
162) -> tuple[int, int]:
163    """Return random `x, y` position within parent.
164
165    Args:
166        parent: The dimensions of the parent container.
167        child: The image object to position.
168
169    Returns:
170        tuple[int, int]: The random (x, y) position.
171    """
172    parentW, parentH = layout.toDimensions(parent)
173    childW, childH = child.size()
174
175    difW, difH = parentW - childW, parentH - childH
176    return [random.random() * (dif / 2) for dif in [difW, difH]]
177
178
179def sampleLuminosity(
180    image_path: str,
181    cell_x: float,
182    cell_y: float,
183    cell_width: float,
184    cell_height: float,
185    sample_points: int = 9,
186) -> float:
187    """
188    Sample luminosity from multiple points within a cell for better accuracy
189
190    Args:
191        image_path: Path to the image
192        cell_x, cell_y: Top-left corner of the cell
193        cell_width, cell_height: Cell dimensions
194        sample_points: Number of sample points (will be made into a square grid)
195    """
196    luminosity_sum = 0
197    valid_samples = 0
198
199    # Calculate grid size (make it square)
200    grid_size = int(sqrt(sample_points))
201
202    # Create sample points in a grid within the cell
203    for i in range(grid_size):
204        for j in range(grid_size):
205            # Calculate sample position within the cell
206            sample_x = cell_x + (i + 0.5) * cell_width / grid_size
207            sample_y = cell_y + (j + 0.5) * cell_height / grid_size
208
209            # Sample pixel color at this point
210            pixel_data = drawBot.imagePixelColor(image_path, (sample_x, sample_y))
211
212            if pixel_data:
213                r, g, b, _ = pixel_data
214                luminosity = 0.299 * r + 0.587 * g + 0.114 * b
215                luminosity_sum += luminosity
216                valid_samples += 1
217
218    # Round to 10 decimals (arbitrary) to fix floating point imprecision
219    return round(luminosity_sum / valid_samples, 10) if valid_samples > 0 else 0
220
221
222def adjustContrast(luminosity: float, contrast: float = 1.0):
223    """
224    S-curve contrast adjustment using sigmoid function
225    - < 1.0: reduces contrast
226    - = 1.0: no change
227    - > 1.0: increases contrast
228    """
229    if contrast == 0:
230        return 0.5  # Gray
231    elif contrast == 1:
232        return luminosity  # Unchanged
233
234    # Convert to range that works well with sigmoid
235    x = (luminosity - 0.5) * contrast
236    # Apply sigmoid function
237    sigmoid = 1 / (1 + exp(-x * 6))  # 6 is steepness factor
238    return sigmoid
239
240
241def renderLineScreen(
242    imagePath: str | drawBotDrawingTools.ImageObject,
243    position: Tuple[float, float],
244    rows: int,
245    cols: int,
246    bleed: Tuple[float, float] = (0, 0),
247    sampleOffset: Tuple[float, float] = (0, 0),
248    contrast: float = 1.0,
249    samplePoints: int = 9,
250    lineCap: LineCap = "butt",
251    minThickness: float = 0.1,
252) -> None:
253    """
254    Karel Martens-style line screen effect
255
256    Args:
257        imagePath: Path to input image
258        rows: Number of grid rows
259        cols: Number of grid columns
260        bleed: Tuple (x, y) - amount to extend cells in each direction
261        sampleOffset: Tuple (x, y) - offset for sampling luminosity
262        contrast: Contrast adjustment factor
263            - < 1.0: reduces contrast
264            - = 1.0: no change
265            - > 1.0: increases contrast
266        samplePoints: Number of points to sample for luminosity calculation
267        lineCap: Line cap style, either "butt" or "round"
268        minThickness: Minimum stroke thickness in pts
269    """
270
271    posX, posY = position
272    imgW, imgH = drawBot.imageSize(imagePath)
273    cellW, cellH = imgW / cols, imgH / rows
274
275    # Extract bleed values
276    bleedX, bleedY = bleed
277    sampleOffsetX, sampleOffsetY = sampleOffset
278
279    # White background
280    drawBot.fill(1)
281    drawBot.rect(0, 0, imgW, imgH)
282
283    # Black lines
284    drawBot.fill(None)
285    drawBot.stroke(0)
286    drawBot.lineCap(lineCap)
287
288    for row in range(rows):
289        for col in range(cols):
290            # Original cell position
291            x = col * cellW
292            y = (rows - 1 - row) * cellH
293
294            # Clamp coordinates to image bounds before sampling luminosity
295            sampleX = max(0, min(x + sampleOffsetX, imgW - (cellW / 2)))
296            sampleY = max(0, min(y + sampleOffsetY, imgH - (cellH / 2)))
297
298            luminosity = sampleLuminosity(
299                imagePath,
300                sampleX,
301                sampleY,
302                cellW,
303                cellH,
304                samplePoints,
305            )
306
307            luminosity = adjustContrast(luminosity, contrast)
308
309            # Map to thickness (invert for dark = thick)
310            thicknessRatio = 1 - luminosity
311            lineW = max(minThickness, thicknessRatio * cellW)
312
313            # Center the line in original cell
314            lineX = x + (cellW - lineW) / 2
315
316            # Apply bleed to extend the drawing area
317            extX = x - bleedX
318            extY = y - bleedY
319            extW = cellW + (2 * bleedX)
320            extH = cellH + (2 * bleedY)
321
322            # Extend line width with horizontal bleed
323            extLineX = lineX - bleedX
324            extLineW = lineW + (2 * bleedX)
325
326            # Clamp to canvas boundaries
327            if (
328                lineW > 0
329                and extX < imgW
330                and extY < imgH
331                and extX + extW > 0
332                and extY + extH > 0
333            ):
334                # Calculate actual drawing bounds
335                actualX = max(0, extX)
336                actualY = max(0, extY)
337                actualW = min(imgW - actualX, extW - (actualX - extX))
338                actualH = min(imgH - actualY, extH - (actualY - extY))
339
340                # Calculate actual line bounds
341                actualLineX = max(0, extLineX)
342                actualLineW = min(
343                    imgW - actualLineX,
344                    extLineW - (actualLineX - extLineX),
345                )
346
347                # Draw vertical line with bleed
348                if actualLineW > 0 and actualW > 0 and actualH > 0:
349                    drawBot.strokeWidth(lineW)
350                    drawBot.line(
351                        (posX + actualLineX + lineW / 2, posY + actualY),
352                        (
353                            posX + actualLineX + lineW / 2,
354                            posY + actualY + actualH,
355                        ),
356                    )
BlendMode: TypeAlias = Literal['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'colorDodge', 'colorBurn', 'softLight', 'hardLight', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'clear', 'copy', 'sourceIn', 'sourceOut', 'sourceAtop', 'destinationOver', 'destinationIn', 'destinationOut', 'destinationAtop', 'xOR', 'plusDarker', 'plusLighter']
LineCap = typing.Literal['butt', 'round']

Omitted square (rarely used).

def setBlendMode( mode: Literal['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'colorDodge', 'colorBurn', 'softLight', 'hardLight', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', 'clear', 'copy', 'sourceIn', 'sourceOut', 'sourceAtop', 'destinationOver', 'destinationIn', 'destinationOut', 'destinationAtop', 'xOR', 'plusDarker', 'plusLighter']):
50def setBlendMode(mode: BlendMode):
51    """Set the blend mode for drawing operations.
52
53    Args:
54        mode: The blend mode to use.
55    """
56    drawBot.blendMode(mode)

Set the blend mode for drawing operations.

Arguments:
  • mode: The blend mode to use.
def renderClouds( dimensions: tuple, zoom=150, dot=10, scale=1, brightness=0, contrast=1.5) -> drawBot.context.tools.imageObject.ImageObject:
121def renderClouds(
122    dimensions: tuple, zoom=150, dot=10, scale=1, brightness=0, contrast=1.5
123) -> drawBotDrawingTools.ImageObject:
124    """Render a cloud-like image with random dot patterns.
125
126    Args:
127        dimensions: The width and height of the output image.
128        zoom: The zoom factor for the dot pattern.
129        dot: The size of the dots.
130        scale: The scale factor for the inner image.
131        brightness: Brightness adjustment.
132        contrast: Contrast adjustment.
133
134    Returns:
135        ImageObject: The generated cloud image.
136    """
137    imgOuter = drawBot.ImageObject()
138    w, h = layout.toDimensions(dimensions)
139
140    with imgOuter:
141        drawBot.size(w, h)
142        drawBot.fill(0.9)
143        drawBot.rect(0, 0, w, h)
144
145        imgInner = drawBot.ImageObject()
146        with imgInner:
147            drawBot.size(*[dim * scale for dim in [w, h]])
148        imgInner.randomGenerator([ceil(dim / zoom) for dim in imgInner.size()])
149        imgInner.lanczosScaleTransform(zoom)
150
151        shiftPos = shiftWithin(dimensions, imgInner)
152        drawBot.image(imgInner, shiftPos)
153
154    imgOuter.colorControls(brightness=brightness, contrast=contrast)
155    imgOuter.dotScreen(width=zoom / dot, angle=45, sharpness=1)
156    imgOuter.colorInvert()
157
158    return imgOuter

Render a cloud-like image with random dot patterns.

Arguments:
  • dimensions: The width and height of the output image.
  • zoom: The zoom factor for the dot pattern.
  • dot: The size of the dots.
  • scale: The scale factor for the inner image.
  • brightness: Brightness adjustment.
  • contrast: Contrast adjustment.
Returns:

ImageObject: The generated cloud image.

def shiftWithin( parent: tuple, child: drawBot.context.tools.imageObject.ImageObject) -> tuple[int, int]:
161def shiftWithin(
162    parent: tuple, child: drawBotDrawingTools.ImageObject
163) -> tuple[int, int]:
164    """Return random `x, y` position within parent.
165
166    Args:
167        parent: The dimensions of the parent container.
168        child: The image object to position.
169
170    Returns:
171        tuple[int, int]: The random (x, y) position.
172    """
173    parentW, parentH = layout.toDimensions(parent)
174    childW, childH = child.size()
175
176    difW, difH = parentW - childW, parentH - childH
177    return [random.random() * (dif / 2) for dif in [difW, difH]]

Return random x, y position within parent.

Arguments:
  • parent: The dimensions of the parent container.
  • child: The image object to position.
Returns:

tuple[int, int]: The random (x, y) position.

def sampleLuminosity( image_path: str, cell_x: float, cell_y: float, cell_width: float, cell_height: float, sample_points: int = 9) -> float:
180def sampleLuminosity(
181    image_path: str,
182    cell_x: float,
183    cell_y: float,
184    cell_width: float,
185    cell_height: float,
186    sample_points: int = 9,
187) -> float:
188    """
189    Sample luminosity from multiple points within a cell for better accuracy
190
191    Args:
192        image_path: Path to the image
193        cell_x, cell_y: Top-left corner of the cell
194        cell_width, cell_height: Cell dimensions
195        sample_points: Number of sample points (will be made into a square grid)
196    """
197    luminosity_sum = 0
198    valid_samples = 0
199
200    # Calculate grid size (make it square)
201    grid_size = int(sqrt(sample_points))
202
203    # Create sample points in a grid within the cell
204    for i in range(grid_size):
205        for j in range(grid_size):
206            # Calculate sample position within the cell
207            sample_x = cell_x + (i + 0.5) * cell_width / grid_size
208            sample_y = cell_y + (j + 0.5) * cell_height / grid_size
209
210            # Sample pixel color at this point
211            pixel_data = drawBot.imagePixelColor(image_path, (sample_x, sample_y))
212
213            if pixel_data:
214                r, g, b, _ = pixel_data
215                luminosity = 0.299 * r + 0.587 * g + 0.114 * b
216                luminosity_sum += luminosity
217                valid_samples += 1
218
219    # Round to 10 decimals (arbitrary) to fix floating point imprecision
220    return round(luminosity_sum / valid_samples, 10) if valid_samples > 0 else 0

Sample luminosity from multiple points within a cell for better accuracy

Arguments:
  • image_path: Path to the image
  • cell_x, cell_y: Top-left corner of the cell
  • cell_width, cell_height: Cell dimensions
  • sample_points: Number of sample points (will be made into a square grid)
def adjustContrast(luminosity: float, contrast: float = 1.0):
223def adjustContrast(luminosity: float, contrast: float = 1.0):
224    """
225    S-curve contrast adjustment using sigmoid function
226    - < 1.0: reduces contrast
227    - = 1.0: no change
228    - > 1.0: increases contrast
229    """
230    if contrast == 0:
231        return 0.5  # Gray
232    elif contrast == 1:
233        return luminosity  # Unchanged
234
235    # Convert to range that works well with sigmoid
236    x = (luminosity - 0.5) * contrast
237    # Apply sigmoid function
238    sigmoid = 1 / (1 + exp(-x * 6))  # 6 is steepness factor
239    return sigmoid

S-curve contrast adjustment using sigmoid function

  • < 1.0: reduces contrast
  • = 1.0: no change
  • > 1.0: increases contrast
def renderLineScreen( imagePath: str | drawBot.context.tools.imageObject.ImageObject, position: Tuple[float, float], rows: int, cols: int, bleed: Tuple[float, float] = (0, 0), sampleOffset: Tuple[float, float] = (0, 0), contrast: float = 1.0, samplePoints: int = 9, lineCap: Literal['butt', 'round'] = 'butt', minThickness: float = 0.1) -> None:
242def renderLineScreen(
243    imagePath: str | drawBotDrawingTools.ImageObject,
244    position: Tuple[float, float],
245    rows: int,
246    cols: int,
247    bleed: Tuple[float, float] = (0, 0),
248    sampleOffset: Tuple[float, float] = (0, 0),
249    contrast: float = 1.0,
250    samplePoints: int = 9,
251    lineCap: LineCap = "butt",
252    minThickness: float = 0.1,
253) -> None:
254    """
255    Karel Martens-style line screen effect
256
257    Args:
258        imagePath: Path to input image
259        rows: Number of grid rows
260        cols: Number of grid columns
261        bleed: Tuple (x, y) - amount to extend cells in each direction
262        sampleOffset: Tuple (x, y) - offset for sampling luminosity
263        contrast: Contrast adjustment factor
264            - < 1.0: reduces contrast
265            - = 1.0: no change
266            - > 1.0: increases contrast
267        samplePoints: Number of points to sample for luminosity calculation
268        lineCap: Line cap style, either "butt" or "round"
269        minThickness: Minimum stroke thickness in pts
270    """
271
272    posX, posY = position
273    imgW, imgH = drawBot.imageSize(imagePath)
274    cellW, cellH = imgW / cols, imgH / rows
275
276    # Extract bleed values
277    bleedX, bleedY = bleed
278    sampleOffsetX, sampleOffsetY = sampleOffset
279
280    # White background
281    drawBot.fill(1)
282    drawBot.rect(0, 0, imgW, imgH)
283
284    # Black lines
285    drawBot.fill(None)
286    drawBot.stroke(0)
287    drawBot.lineCap(lineCap)
288
289    for row in range(rows):
290        for col in range(cols):
291            # Original cell position
292            x = col * cellW
293            y = (rows - 1 - row) * cellH
294
295            # Clamp coordinates to image bounds before sampling luminosity
296            sampleX = max(0, min(x + sampleOffsetX, imgW - (cellW / 2)))
297            sampleY = max(0, min(y + sampleOffsetY, imgH - (cellH / 2)))
298
299            luminosity = sampleLuminosity(
300                imagePath,
301                sampleX,
302                sampleY,
303                cellW,
304                cellH,
305                samplePoints,
306            )
307
308            luminosity = adjustContrast(luminosity, contrast)
309
310            # Map to thickness (invert for dark = thick)
311            thicknessRatio = 1 - luminosity
312            lineW = max(minThickness, thicknessRatio * cellW)
313
314            # Center the line in original cell
315            lineX = x + (cellW - lineW) / 2
316
317            # Apply bleed to extend the drawing area
318            extX = x - bleedX
319            extY = y - bleedY
320            extW = cellW + (2 * bleedX)
321            extH = cellH + (2 * bleedY)
322
323            # Extend line width with horizontal bleed
324            extLineX = lineX - bleedX
325            extLineW = lineW + (2 * bleedX)
326
327            # Clamp to canvas boundaries
328            if (
329                lineW > 0
330                and extX < imgW
331                and extY < imgH
332                and extX + extW > 0
333                and extY + extH > 0
334            ):
335                # Calculate actual drawing bounds
336                actualX = max(0, extX)
337                actualY = max(0, extY)
338                actualW = min(imgW - actualX, extW - (actualX - extX))
339                actualH = min(imgH - actualY, extH - (actualY - extY))
340
341                # Calculate actual line bounds
342                actualLineX = max(0, extLineX)
343                actualLineW = min(
344                    imgW - actualLineX,
345                    extLineW - (actualLineX - extLineX),
346                )
347
348                # Draw vertical line with bleed
349                if actualLineW > 0 and actualW > 0 and actualH > 0:
350                    drawBot.strokeWidth(lineW)
351                    drawBot.line(
352                        (posX + actualLineX + lineW / 2, posY + actualY),
353                        (
354                            posX + actualLineX + lineW / 2,
355                            posY + actualY + actualH,
356                        ),
357                    )

Karel Martens-style line screen effect

Arguments:
  • imagePath: Path to input image
  • rows: Number of grid rows
  • cols: Number of grid columns
  • bleed: Tuple (x, y) - amount to extend cells in each direction
  • sampleOffset: Tuple (x, y) - offset for sampling luminosity
  • contrast: Contrast adjustment factor
    • < 1.0: reduces contrast
    • = 1.0: no change
    • > 1.0: increases contrast
  • samplePoints: Number of points to sample for luminosity calculation
  • lineCap: Line cap style, either "butt" or "round"
  • minThickness: Minimum stroke thickness in pts