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