{name}
\n\n') 302 | htmlfile.write(' '.join(doclines)) 303 | htmlfile.write('\n
\n') 304 | htmlfile.write(highlighted_src) 305 | htmlfile.write('\n') 306 | htmlfile.close() 307 | 308 | # Post process HTML by adding anchors, etc. 309 | htmldoc = open(resultfile).read() 310 | htmldoc = htmldoc.replace('$quickref$', quickref) 311 | htmldoc = htmldoc.replace('', version + '\n')
312 | soup = BeautifulSoup(htmldoc, 'html.parser')
313 | for tag in 'h2 h3 h4'.split():
314 | headings = soup.find_all(tag)
315 | for heading in headings:
316 | content = heading.contents[0].strip()
317 | id = content.replace(' ', '_').lower()
318 | heading["id"] = id
319 | anchor = soup.new_tag('a', href='#' + id)
320 | anchor.string = content
321 | heading.contents[0].replace_with(anchor)
322 | open(resultfile, 'w').write(str(soup))
323 |
324 | generate_page(qualify('index.md'), qualify('index.html'), False)
325 | generate_page(qualify('reference.md'), qualify('reference.html'), True)
326 |
327 | # Test rotations and flips
328 |
329 | gibbons = snowy.load(qualify('gibbons.jpg'))
330 | gibbons = snowy.resize(gibbons, width=gibbons.shape[1] // 5)
331 | gibbons90 = snowy.rotate(gibbons, 90)
332 | gibbons180 = snowy.rotate(gibbons, 180)
333 | gibbons270 = snowy.rotate(gibbons, 270)
334 | hflipped = snowy.hflip(gibbons)
335 | vflipped = snowy.vflip(gibbons)
336 | snowy.export(snowy.hstack([gibbons, gibbons180, vflipped],
337 | border_width=4, border_value=[0.5,0,0]), qualify("xforms.png"))
338 |
339 | # Test noise generation
340 |
341 | n = snowy.generate_noise(100, 100, frequency=4, seed=42, wrapx=True)
342 | n = np.hstack([n, n])
343 | n = 0.5 + 0.5 * n
344 | snowy.show(n)
345 | snowy.export(n, qualify('noise.png'))
346 |
347 | # First try minifying grayscale
348 |
349 | gibbons = snowy.load(qualify('snowy.jpg'))
350 | gibbons = np.swapaxes(gibbons, 0, 2)
351 | gibbons = np.swapaxes(gibbons[0], 0, 1)
352 | gibbons = snowy.reshape(gibbons)
353 | source = snowy.resize(gibbons, height=200)
354 | blurry = snowy.blur(source, radius=4.0)
355 | diptych_filename = qualify('diptych.png')
356 | snowy.export(snowy.hstack([source, blurry]), diptych_filename)
357 | optimize(diptych_filename)
358 | snowy.show(diptych_filename)
359 |
360 | # Next try color
361 |
362 | gibbons = snowy.load(qualify('snowy.jpg'))
363 | source = snowy.resize(gibbons, height=200)
364 | blurry = snowy.blur(source, radius=4.0)
365 | diptych_filename = qualify('diptych.png')
366 | snowy.export(snowy.hstack([source, blurry]), diptych_filename)
367 | optimize(diptych_filename)
368 | snowy.show(diptych_filename)
369 |
370 | # Moving on to magnification...
371 |
372 | parrot = snowy.load(qualify('parrot.png'))
373 | scale = 6
374 | nearest = snowy.resize(parrot, width=32*scale, filter=snowy.NEAREST)
375 | mitchell = snowy.resize(parrot, height=26*scale)
376 | diptych_filename = qualify('diptych-parrot.png')
377 | parrot = snowy.hstack([nearest, mitchell])
378 | parrot = snowy.extract_rgb(parrot)
379 | snowy.export(parrot, diptych_filename)
380 | optimize(diptych_filename)
381 | snowy.show(diptych_filename)
382 |
383 | # EXR cropping
384 |
385 | sunset = snowy.load(qualify('small.exr'), False)
386 | sunset = sunset[:100,:,:] / 50.0
387 | cropped_filename = qualify('cropped-sunset.png')
388 | snowy.export(sunset, cropped_filename)
389 | optimize(cropped_filename)
390 | snowy.show(cropped_filename)
391 |
392 | # Alpha composition
393 |
394 | icon = snowy.load(qualify('snowflake.png'))
395 | icon = snowy.resize(icon, height=100)
396 | sunset[:100,200:300] = snowy.compose(sunset[:100,200:300], icon)
397 | snowy.export(sunset, qualify('composed.png'))
398 | optimize(qualify('composed.png'))
399 | snowy.show(sunset)
400 |
401 | # Drop shadows
402 |
403 | shadow = np.zeros([150, 150, 4])
404 | shadow[25:-25,25:-25,:] = icon
405 |
406 | white = shadow.copy()
407 | white[:,:,:3] = 1.0 - white[:,:,:3]
408 |
409 | shadow = snowy.blur(shadow, radius=10.0)
410 | shadow = snowy.compose(shadow, shadow)
411 | shadow = snowy.compose(shadow, shadow)
412 | shadow = snowy.compose(shadow, shadow)
413 |
414 | dropshadow = snowy.compose(shadow, white)
415 | snowy.export(dropshadow, qualify('dropshadow.png'))
416 | optimize(qualify('dropshadow.png'))
417 |
418 | STEPPED_PALETTE = [
419 | 000, 0x203060 ,
420 | 64, 0x2C316F ,
421 | 125, 0x2C316F ,
422 | 125, 0x46769D ,
423 | 126, 0x46769D ,
424 | 127, 0x324060 ,
425 | 131, 0x324060 ,
426 | 132, 0x9C907D ,
427 | 137, 0x9C907D ,
428 | 137, 0x719457 ,
429 | 170, 0x719457 , # Light green
430 | 170, 0x50735A ,
431 | 180, 0x50735A ,
432 | 180, 0x9FA881 ,
433 | 200, 0x9FA881 ,
434 | 250, 0xFFFFFF ,
435 | 255, 0xFFFFFF
436 | ]
437 |
438 | SMOOTH_PALETTE = [
439 | 000, 0x203060 , # Dark Blue
440 | 126, 0x2C316F , # Light Blue
441 | 127, 0xE0F0A0 , # Yellow
442 | 128, 0x719457 , # Dark Green
443 | 200, 0xFFFFFF , # White
444 | 255, 0xFFFFFF ] # White
445 |
446 | from scipy import interpolate
447 |
448 | def applyColorGradient(elevation_image, gradient_image):
449 | xvals = np.arange(256)
450 | yvals = gradient_image[0]
451 | apply_lut = interpolate.interp1d(xvals, yvals, axis=0)
452 | return apply_lut(snowy.unshape(np.clip(elevation_image, 0, 255)))
453 |
454 | def create_falloff(w, h, radius=0.4, cx=0.5, cy=0.5):
455 | hw, hh = 0.5 / w, 0.5 / h
456 | x = np.linspace(hw, 1 - hw, w)
457 | y = np.linspace(hh, 1 - hh, h)
458 | u, v = np.meshgrid(x, y, sparse=True)
459 | d2 = (u-cx)**2 + (v-cy)**2
460 | return 1-snowy.unitize(snowy.reshape(d2))
461 |
462 | c0 = create_circle(200, 200, 0.3)
463 | c1 = create_circle(200, 200, 0.08, 0.8, 0.8)
464 | c0 = np.clip(c0 + c1, 0, 1)
465 | circles = snowy.add_border(c0, value=1)
466 | sdf = snowy.unitize(snowy.generate_sdf(circles != 0.0))
467 | stack = snowy.hstack([circles, sdf])
468 | snowy.export(stack, qualify('sdf.png'))
469 | snowy.show(stack)
470 |
471 | # Islands
472 | def create_island(seed, gradient, freq=3.5):
473 | w, h = 750, 512
474 | falloff = create_falloff(w, h)
475 | n1 = 1.000 * snowy.generate_noise(w, h, freq*1, seed+0)
476 | n2 = 0.500 * snowy.generate_noise(w, h, freq*2, seed+1)
477 | n3 = 0.250 * snowy.generate_noise(w, h, freq*4, seed+2)
478 | n4 = 0.125 * snowy.generate_noise(w, h, freq*8, seed+3)
479 | elevation = falloff * (falloff / 2 + n1 + n2 + n3 + n4)
480 | mask = elevation < 0.4
481 | elevation = snowy.unitize(snowy.generate_sdf(mask))
482 | if GRAY_ISLAND:
483 | return (1 - mask) * np.power(elevation, 3.0)
484 | elevation = snowy.generate_sdf(mask) - 100 * n4
485 | mask = np.where(elevation < 0, 1, 0)
486 | el = 128 + 127 * elevation / np.amax(elevation)
487 | return applyColorGradient(el, gradient)
488 |
489 | def createColorGradient(pal):
490 | inds = pal[0::2]
491 | cols = np.array(pal[1::2])
492 | red, grn, blu = cols >> 16, cols >> 8, cols
493 | cols = [c & 0xff for c in [red, grn, blu]]
494 | cols = [interpolate.interp1d(inds, c) for c in cols]
495 | img = np.arange(0, 255)
496 | img = np.dstack([fn(img) for fn in cols])
497 | return snowy.resize(img, 256, 32)
498 |
499 | gradient = createColorGradient(STEPPED_PALETTE)
500 | snowy.export(snowy.add_border(gradient), qualify('gradient.png'))
501 | isles = []
502 | for i in range(6):
503 | isle = create_island(i * 5, gradient)
504 | isle = snowy.resize(isle, width=isle.shape[1] // 3)
505 | isles.append(isle)
506 | snowy.export(isles[2], qualify('island.png'))
507 | optimize(qualify('island.png'))
508 | isles = snowy.hstack(isles)
509 | snowy.export(isles, qualify('isles.png'))
510 |
511 | def draw_quad():
512 | verts = np.array([[-0.67608007, 0.38439575, 3.70544936, 0., 0. ],
513 | [-0.10726266, 0.38439575, 2.57742041, 1., 0. ],
514 | [-0.10726266, -0.96069041, 2.57742041, 1., 1. ],
515 | [-0.67608007, -0.96069041, 3.70544936, 0., 1. ]])
516 | texture = snowy.load(qualify('../tests/texture.png'))
517 | target = np.full((1080, 1920, 4), (0.54, 0.54, 0.78, 1.00),
518 | dtype=np.float32)
519 | snowy.draw_polygon(target, texture, verts)
520 | target = snowy.resize(target[400:770, 700:1000], height = 256)
521 | texture = snowy.resize(texture, height = 256)
522 | quad = snowy.hstack([texture, target])
523 | snowy.export(quad, qualify('quad.png'))
524 | snowy.show(quad)
525 |
526 | draw_quad()
527 |
--------------------------------------------------------------------------------
/docs/gibbons.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/gibbons.jpg
--------------------------------------------------------------------------------
/docs/gradient.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/gradient.png
--------------------------------------------------------------------------------
/docs/ground.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/ground.jpg
--------------------------------------------------------------------------------
/docs/ground2x2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/ground2x2.jpg
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
7 |
13 | Snowy
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
98 |
178 |
179 |
180 |
194 |
195 | v0.0.9 ~ f0a51f7
196 | Snowy 
197 | User's Guide | API Reference
198 | Snowy is a tiny module for manipulating and generating floating-point images.
199 |
200 | - Small and flat API (free functions only).
201 | - Written purely in Python 3.
202 | - Accelerated with numba.
203 | - Configurable boundaries (wrap modes).
204 |
205 | Snowy does not define a special class for images. Instead, images are always three-dimensional
206 | numpy arrays in row-major order.
207 | For example, RGB images have shape [height,width,3]
and grayscale images have shape
208 | [height,width,1]
. Snowy provides some utility functions that make it easy to work with other
209 | modules (see interop).
210 | Snowy is not an Image IO library, but for convenience it provides load and
211 | export functions that have limited support for PNG, EXR, and JPEG.
212 | If you're interested in tone mapping and other HDR operations, check out the
213 | hydra module. If you wish to simply load / store raw
214 | floating-point data, consider using npy files instead of image files. The relevant functions are
215 | numpy.load and
216 | numpy.save.
217 | Installing
218 | To install and update snowy, do this:
219 | pip3 install -U snowy
220 | Examples
221 | Resize and blur
222 | This snippet does a resize, then a blur, then horizontally concatenates the two
223 | images.
224 | import snowy
225 |
226 | source = snowy.load('poodle.png')
227 | source = snowy.resize(source, height=200)
228 | blurry = snowy.blur(source, radius=4.0)
229 | snowy.export(snowy.hstack([source, blurry]), 'diptych.png')
230 |
231 |
232 | The next snippet first magnifies an image using a nearest-neighbor filter, then using the default
233 | Mitchell filter.
234 | parrot = snowy.load('parrot.png')
235 | height, width = parrot.shape[:2]
236 | nearest = snowy.resize(parrot, width * 6, filter=snowy.NEAREST)
237 | mitchell = snowy.resize(parrot, width * 6)
238 | snowy.show(snowy.hstack([nearest, mitchell]))
239 |
240 |
241 | Rotate and flip
242 | gibbons = snowy.load('gibbons.jpg')
243 | rotated = snowy.rotate(gibbons, 180)
244 | flipped = snowy.vflip(gibbons)
245 | triptych = snowy.hstack([gibbons, rotated, flipped],
246 | border_width=4, border_value=[0.5,0,0])
247 |
248 |
249 | Cropping
250 | If you need to crop an image, just use numpy slicing.
251 | For example, this loads an OpenEXR image then crops out the top half:
252 | sunrise = snowy.load('sunrise.exr')
253 | cropped_sunrise = sunrise[:100,:,:]
254 | snowy.show(cropped_sunrise / 50.0) # darken the image
255 |
256 |
257 | Alpha composition
258 | To copy a section of one image into another, simply use numpy slicing.
259 | However, to achieve "source-over" style alpha blending, using raw numpy math would be cumbersome.
260 | Snowy provides compose to make this easier:
261 | icon = snowy.load('snowflake.png')
262 | icon = snowy.resize(snowflake, height=100)
263 | sunset[:100,200:300] = snowy.compose(sunset[:100,200:300], icon)
264 | snowy.show(sunset)
265 |
266 |
267 | Drop shadows
268 | Combining operations like blur and compose can be
269 | used to create a drop shadow:
270 | # Extend the 100x100 snowflake image on 4 sides to give room for blur.
271 | shadow = np.zeros([150, 150, 4])
272 | shadow[25:-25,25:-25,:] = icon
273 |
274 | # Invert the colors but not the alpha.
275 | white = shadow.copy()
276 | white[:,:,:3] = 1.0 - white[:,:,:3]
277 |
278 | # Blur the shadow, then "strengthen" it.
279 | shadow = snowy.blur(shadow, radius=10.0)
280 | shadow = snowy.compose(shadow, shadow)
281 | shadow = snowy.compose(shadow, shadow)
282 | shadow = snowy.compose(shadow, shadow)
283 |
284 | # Compose the white flake onto its shadow.
285 | dropshadow = snowy.compose(shadow, white)
286 |
287 |
288 | Gradient noise
289 | Snowy's generate_noise
function generates a single-channel image whose values are
290 | in [-1, +1]. Here we create a square noise image that can be tiled horizontally:
291 | n = snowy.generate_noise(100, 100, frequency=4, seed=42, wrapx=True)
292 | n = np.hstack([n, n])
293 | snowy.show(0.5 + 0.5 * n)
294 |
295 |
296 | If you're interested in other types of noise, or if you need a super-fast noise generator, you might
297 | want to try pyfastnoisesimd.
298 | Distance fields
299 | This example uses generate_sdf
to create a signed distance field from a monochrome picture of two circles
300 | enclosed by a square. Note the usage of unitize
to adjust the values into the [0,1]
range.
301 | circles = snowy.load('circles.png')
302 | sdf = snowy.unitize(snowy.generate_sdf(circles != 0.0))
303 | snowy.show(snowy.hstack([circles, sdf]))
304 |
305 |
306 | Image generation
307 | Combining Snowy's unique features with numpy can be used to create interesting procedural images.
308 | The following example creates an elevation map for an imaginary island.
309 | def create_falloff(w, h, radius=0.4, cx=0.5, cy=0.5):
310 | hw, hh = 0.5 / w, 0.5 / h
311 | x = np.linspace(hw, 1 - hw, w)
312 | y = np.linspace(hh, 1 - hh, h)
313 | u, v = np.meshgrid(x, y, sparse=True)
314 | d2 = (u-cx)**2 + (v-cy)**2
315 | return 1-snowy.unitize(snowy.reshape(d2))
316 |
317 | def create_island(seed, freq=3.5):
318 | w, h = 750, 512
319 | falloff = create_falloff(w, h)
320 | n1 = 1.000 * snowy.generate_noise(w, h, freq*1, seed+0)
321 | n2 = 0.500 * snowy.generate_noise(w, h, freq*2, seed+1)
322 | n3 = 0.250 * snowy.generate_noise(w, h, freq*4, seed+2)
323 | n4 = 0.125 * snowy.generate_noise(w, h, freq*8, seed+3)
324 | elevation = falloff * (falloff / 2 + n1 + n2 + n3 + n4)
325 | mask = elevation < 0.4
326 | elevation = snowy.unitize(snowy.generate_sdf(mask))
327 | return (1 - mask) * np.power(elevation, 3.0)
328 |
329 | snowy.export(create_island(10), 'island.png')
330 |
331 | 
332 | Snowy also offers the compute_skylight and
333 | compute_normals functions to help with 3D rendering. These
334 | functions were used to create the following images.
335 | 
336 | The first panel shows ambient occlusion generated by compute_skylight
, the second panel shows
337 | the normal map generated by compute_normals
, the right two panels use numpy to add diffuse
338 | lighting and a color gradient. The code for this is in
339 | test_lighting.py.
340 | Wrap modes
341 | Snowy's blur, resize,
342 | generate_noise, and generate_sdf
343 | functions all take wrapx
and wrapy
arguments, both of which default to False
. These arguments
344 | tell Snowy how to sample from outside the boundaries of the source image or noise function.
345 | To help understand these arguments, consider this tileable image and its 2x2 tiling:
346 |
347 |
348 |
349 | Next, let's try blurring the tile naively:
350 |
351 | See the seams? Now let's blur it with wrapx
and wrapy
set to True
when we call
352 | blur:
353 |
354 | Wrappable gradient noise
355 | The wrap arguments are also useful for 2D noise. One way of making tileable gradient noise is to
356 | sample 3D noise on the surface of a cylinder, torus, or cube. However Snowy can do this more
357 | efficiently by generating 2D noise with modulus arithmetic.
358 | Here we created a 128x256 tile using generate_noise without the
359 | wrapx
argument, then horizontally tiled it twice:
360 |
361 | Here's another tiling of gradient noise, but this time the tile was generated with wrapx
set to
362 | True
:
363 |
364 | Wrappable distance fields
365 | Snowy's generate_sdf function also takes wrap arguments. For example
366 | here's a distance field in a 4x2 tiling:
367 |
368 | Here's the same distance field, this time with wrapx and wrapy set to True
:
369 |
370 | Drawing quads
371 | Snowy can also rasterize convex textured polygons. For example:
372 | verts = np.array([
373 | [-0.67, 0.38, 3.70, 0., 0. ],
374 | [-0.10, 0.38, 2.57, 1., 0. ],
375 | [-0.10, -0.96, 2.57, 1., 1. ],
376 | [-0.67, -0.96, 3.70, 0., 1. ]])
377 | texture = snowy.load('texture.png')
378 | background = np.full((1080, 1920, 4), (0.54, 0.54, 0.78, 1.00))
379 | snowy.draw_polygon(background, texture, verts)
380 | snowy.show(background)
381 |
382 |
383 | Each vertex is specified as a 5-tuple to enable perspective-correct
384 | interpolation. For more information see
385 | draw_polygon.
386 | Interop
387 | Snowy's algorithms require images to be row-major three-dimensional float64
numpy arrays, with
388 | color channels living in the trailing dimension. If you're working with another module that does not
389 | follow this convention, consider using one of the following interop functions.
390 |
391 | - To add or remove the trailing 1 from the shape of grayscale images, use
392 | reshape and unshape.
393 | - To swap color channels in or out of the leading dimension, use
394 | to_planar and from_planar.
395 | - To cast between
float64
and other types, just use numpy. For example,
396 | np.uint8(myimg * 255)
or np.float64(myimg) / 255
.
397 | - To swap rows with columns, use numpy's
398 | swapaxes function.
399 |
400 |
401 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Snowy
2 |
3 | **User's Guide** | [API Reference](reference.html)
4 |
5 | Snowy is a tiny module for manipulating and generating floating-point images.
6 |
7 | - Small and flat API (free functions only).
8 | - Written purely in Python 3.
9 | - Accelerated with [numba](https://numba.pydata.org/).
10 | - Configurable boundaries ([wrap modes](#wrap_modes)).
11 |
12 | Snowy does not define a special class for images. Instead, **images are always three-dimensional
13 | numpy arrays** in row-major order.
14 |
15 | #### aside
16 |
17 | For example, RGB images have shape `[height,width,3]` and grayscale images have shape
18 | `[height,width,1]`. Snowy provides some utility functions that make it easy to work with other
19 | modules (see [interop](#interop)).
20 |
21 | Snowy is not an Image IO library, but for convenience it provides [load](reference.html#load) and
22 | [export](reference.html#export) functions that have limited support for PNG, EXR, and JPEG.
23 |
24 | #### aside
25 |
26 | If you're interested in tone mapping and other HDR operations, check out the
27 | [hydra](https://github.com/tatsy/hydra) module. If you wish to simply load / store raw
28 | floating-point data, consider using npy files instead of image files. The relevant functions are
29 | [numpy.load](https://docs.scipy.org/doc/numpy/reference/generated/numpy.load.html) and
30 | [numpy.save](https://docs.scipy.org/doc/numpy/reference/generated/numpy.save.html).
31 |
32 | ## Installing
33 |
34 | To install and update snowy, do this:
35 |
36 | `pip3 install -U snowy`
37 |
38 | ## Examples
39 |
40 | ### Resize and blur
41 |
42 | This snippet does a resize, then a blur, then horizontally concatenates the two
43 | images.
44 |
45 | ```python
46 | import snowy
47 |
48 | source = snowy.load('poodle.png')
49 | source = snowy.resize(source, height=200)
50 | blurry = snowy.blur(source, radius=4.0)
51 | snowy.export(snowy.hstack([source, blurry]), 'diptych.png')
52 | ```
53 |
54 |
55 |
56 | The next snippet first magnifies an image using a nearest-neighbor filter, then using the default
57 | Mitchell filter.
58 |
59 | ```python
60 | parrot = snowy.load('parrot.png')
61 | height, width = parrot.shape[:2]
62 | nearest = snowy.resize(parrot, width * 6, filter=snowy.NEAREST)
63 | mitchell = snowy.resize(parrot, width * 6)
64 | snowy.show(snowy.hstack([nearest, mitchell]))
65 | ```
66 |
67 |
68 |
69 | ### Rotate and flip
70 |
71 | ```python
72 | gibbons = snowy.load('gibbons.jpg')
73 | rotated = snowy.rotate(gibbons, 180)
74 | flipped = snowy.vflip(gibbons)
75 | triptych = snowy.hstack([gibbons, rotated, flipped],
76 | border_width=4, border_value=[0.5,0,0])
77 | ```
78 |
79 |
80 |
81 | ### Cropping
82 |
83 | If you need to crop an image, just use numpy slicing.
84 |
85 | For example, this loads an OpenEXR image then crops out the top half:
86 |
87 | ```python
88 | sunrise = snowy.load('sunrise.exr')
89 | cropped_sunrise = sunrise[:100,:,:]
90 | snowy.show(cropped_sunrise / 50.0) # darken the image
91 | ```
92 |
93 |
94 |
95 | ### Alpha composition
96 |
97 | To copy a section of one image into another, simply use numpy slicing.
98 |
99 | However, to achieve "source-over" style alpha blending, using raw numpy math would be cumbersome.
100 | Snowy provides [compose](reference.html#compose) to make this easier:
101 |
102 | ```python
103 | icon = snowy.load('snowflake.png')
104 | icon = snowy.resize(snowflake, height=100)
105 | sunset[:100,200:300] = snowy.compose(sunset[:100,200:300], icon)
106 | snowy.show(sunset)
107 | ```
108 |
109 |
110 |
111 | ### Drop shadows
112 |
113 | Combining operations like [blur](reference.html#blur) and [compose](reference.html#compose) can be
114 | used to create a drop shadow:
115 |
116 | ```python
117 | # Extend the 100x100 snowflake image on 4 sides to give room for blur.
118 | shadow = np.zeros([150, 150, 4])
119 | shadow[25:-25,25:-25,:] = icon
120 |
121 | # Invert the colors but not the alpha.
122 | white = shadow.copy()
123 | white[:,:,:3] = 1.0 - white[:,:,:3]
124 |
125 | # Blur the shadow, then "strengthen" it.
126 | shadow = snowy.blur(shadow, radius=10.0)
127 | shadow = snowy.compose(shadow, shadow)
128 | shadow = snowy.compose(shadow, shadow)
129 | shadow = snowy.compose(shadow, shadow)
130 |
131 | # Compose the white flake onto its shadow.
132 | dropshadow = snowy.compose(shadow, white)
133 | ```
134 |
135 |
136 |
137 | ### Gradient noise
138 |
139 | Snowy's `generate_noise` function generates a single-channel image whose values are
140 | in [-1, +1]. Here we create a square noise image that can be tiled horizontally:
141 |
142 | ```python
143 | n = snowy.generate_noise(100, 100, frequency=4, seed=42, wrapx=True)
144 | n = np.hstack([n, n])
145 | snowy.show(0.5 + 0.5 * n)
146 | ```
147 |
148 |
149 |
150 | #### aside
151 |
152 | If you're interested in other types of noise, or if you need a super-fast noise generator, you might
153 | want to try [pyfastnoisesimd](https://github.com/robbmcleod/pyfastnoisesimd).
154 |
155 | ### Distance fields
156 |
157 | This example uses `generate_sdf` to create a signed distance field from a monochrome picture of two circles
158 | enclosed by a square. Note the usage of `unitize` to adjust the values into the `[0,1]` range.
159 |
160 | ```python
161 | circles = snowy.load('circles.png')
162 | sdf = snowy.unitize(snowy.generate_sdf(circles != 0.0))
163 | snowy.show(snowy.hstack([circles, sdf]))
164 | ```
165 |
166 |
167 |
168 | ### Image generation
169 |
170 | Combining Snowy's unique features with numpy can be used to create interesting procedural images.
171 | The following example creates an elevation map for an imaginary island.
172 |
173 | ```python
174 | def create_falloff(w, h, radius=0.4, cx=0.5, cy=0.5):
175 | hw, hh = 0.5 / w, 0.5 / h
176 | x = np.linspace(hw, 1 - hw, w)
177 | y = np.linspace(hh, 1 - hh, h)
178 | u, v = np.meshgrid(x, y, sparse=True)
179 | d2 = (u-cx)**2 + (v-cy)**2
180 | return 1-snowy.unitize(snowy.reshape(d2))
181 |
182 | def create_island(seed, freq=3.5):
183 | w, h = 750, 512
184 | falloff = create_falloff(w, h)
185 | n1 = 1.000 * snowy.generate_noise(w, h, freq*1, seed+0)
186 | n2 = 0.500 * snowy.generate_noise(w, h, freq*2, seed+1)
187 | n3 = 0.250 * snowy.generate_noise(w, h, freq*4, seed+2)
188 | n4 = 0.125 * snowy.generate_noise(w, h, freq*8, seed+3)
189 | elevation = falloff * (falloff / 2 + n1 + n2 + n3 + n4)
190 | mask = elevation < 0.4
191 | elevation = snowy.unitize(snowy.generate_sdf(mask))
192 | return (1 - mask) * np.power(elevation, 3.0)
193 |
194 | snowy.export(create_island(10), 'island.png')
195 | ```
196 |
197 | 
198 |
199 | Snowy also offers the [compute_skylight](reference.html#compute_skylight) and
200 | [compute_normals](reference.html#compute_normals) functions to help with 3D rendering. These
201 | functions were used to create the following images.
202 |
203 | 
204 |
205 | The first panel shows ambient occlusion generated by `compute_skylight`, the second panel shows
206 | the normal map generated by `compute_normals`, the right two panels use numpy to add diffuse
207 | lighting and a color gradient. The code for this is in
208 | [test_lighting.py](https://github.com/prideout/snowy/blob/master/tests/test_lighting.py).
209 |
210 | ## Wrap modes
211 |
212 | Snowy's [blur](reference.html#blur), [resize](reference.html#resize),
213 | [generate_noise](reference.html#generate_noise), and [generate_sdf](reference.html#generate_sdf)
214 | functions all take `wrapx` and `wrapy` arguments, both of which default to `False`. These arguments
215 | tell Snowy how to sample from outside the boundaries of the source image or noise function.
216 |
217 | To help understand these arguments, consider this tileable image and its 2x2 tiling:
218 |
219 |
220 |
221 |
222 |
223 | Next, let's try blurring the tile naively:
224 |
225 |
226 |
227 | See the seams? Now let's blur it with `wrapx` and `wrapy` set to `True` when we call
228 | [blur](reference.html#blur):
229 |
230 |
231 |
232 | ### Wrappable gradient noise
233 |
234 | The wrap arguments are also useful for 2D noise. One way of making tileable gradient noise is to
235 | sample 3D noise on the surface of a cylinder, torus, or cube. However Snowy can do this more
236 | efficiently by generating 2D noise with modulus arithmetic.
237 |
238 | Here we created a 128x256 tile using [generate_noise](reference.html#generate_noise) without the
239 | `wrapx` argument, then horizontally tiled it twice:
240 |
241 |
242 |
243 | Here's another tiling of gradient noise, but this time the tile was generated with `wrapx` set to
244 | `True`:
245 |
246 |
247 |
248 | ### Wrappable distance fields
249 |
250 | Snowy's [generate_sdf](reference.html#generate_sdf) function also takes wrap arguments. For example
251 | here's a distance field in a 4x2 tiling:
252 |
253 |
254 |
255 | Here's the same distance field, this time with wrapx and wrapy set to `True`:
256 |
257 |
258 |
259 | ## Drawing quads
260 |
261 | Snowy can also rasterize convex textured polygons. For example:
262 |
263 | ```python
264 | verts = np.array([
265 | [-0.67, 0.38, 3.70, 0., 0. ],
266 | [-0.10, 0.38, 2.57, 1., 0. ],
267 | [-0.10, -0.96, 2.57, 1., 1. ],
268 | [-0.67, -0.96, 3.70, 0., 1. ]])
269 | texture = snowy.load('texture.png')
270 | background = np.full((1080, 1920, 4), (0.54, 0.54, 0.78, 1.00))
271 | snowy.draw_polygon(background, texture, verts)
272 | snowy.show(background)
273 | ```
274 |
275 |
276 |
277 | Each vertex is specified as a 5-tuple to enable perspective-correct
278 | interpolation. For more information see
279 | [draw_polygon](reference.html#draw_polygon).
280 |
281 | ## Interop
282 |
283 | Snowy's algorithms require images to be row-major three-dimensional `float64` numpy arrays, with
284 | color channels living in the trailing dimension. If you're working with another module that does not
285 | follow this convention, consider using one of the following interop functions.
286 |
287 | - To add or remove the trailing 1 from the shape of grayscale images, use
288 | [reshape](reference.html#reshape) and [unshape](reference.html#unshape).
289 | - To swap color channels in or out of the leading dimension, use
290 | [to_planar](reference.html#to_planar) and [from_planar](reference.html#from_planar).
291 | - To cast between `float64` and other types, just use numpy. For example,
292 | `np.uint8(myimg * 255)` or `np.float64(myimg) / 255`.
293 | - To swap rows with columns, use numpy's
294 | [swapaxes function](https://docs.scipy.org/doc/numpy/reference/generated/numpy.swapaxes.html).
295 |
--------------------------------------------------------------------------------
/docs/island.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/island.png
--------------------------------------------------------------------------------
/docs/island_strip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/island_strip.png
--------------------------------------------------------------------------------
/docs/isles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/isles.png
--------------------------------------------------------------------------------
/docs/noise.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/noise.png
--------------------------------------------------------------------------------
/docs/parrot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/parrot.png
--------------------------------------------------------------------------------
/docs/quad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/docs/quad.png
--------------------------------------------------------------------------------
/docs/reference.md:
--------------------------------------------------------------------------------
1 | # Snowy
2 |
3 | [User's Guide](index.html) | **API Reference**
4 |
5 | ---
6 |
7 |
Snowy 
197 | User's Guide | API Reference
198 |Snowy is a tiny module for manipulating and generating floating-point images.
199 |-
200 |
- Small and flat API (free functions only). 201 |
- Written purely in Python 3. 202 |
- Accelerated with numba. 203 |
- Configurable boundaries (wrap modes). 204 |
Snowy does not define a special class for images. Instead, images are always three-dimensional 206 | numpy arrays in row-major order.
207 |For example, RGB images have shape [height,width,3]
and grayscale images have shape
208 | [height,width,1]
. Snowy provides some utility functions that make it easy to work with other
209 | modules (see interop).
Snowy is not an Image IO library, but for convenience it provides load and 211 | export functions that have limited support for PNG, EXR, and JPEG.
212 |If you're interested in tone mapping and other HDR operations, check out the 213 | hydra module. If you wish to simply load / store raw 214 | floating-point data, consider using npy files instead of image files. The relevant functions are 215 | numpy.load and 216 | numpy.save.
217 |Installing
218 |To install and update snowy, do this:
219 |pip3 install -U snowy
Examples
221 |Resize and blur
222 |This snippet does a resize, then a blur, then horizontally concatenates the two 223 | images.
224 |import snowy
225 |
226 | source = snowy.load('poodle.png')
227 | source = snowy.resize(source, height=200)
228 | blurry = snowy.blur(source, radius=4.0)
229 | snowy.export(snowy.hstack([source, blurry]), 'diptych.png')
230 |

The next snippet first magnifies an image using a nearest-neighbor filter, then using the default 233 | Mitchell filter.
234 |parrot = snowy.load('parrot.png')
235 | height, width = parrot.shape[:2]
236 | nearest = snowy.resize(parrot, width * 6, filter=snowy.NEAREST)
237 | mitchell = snowy.resize(parrot, width * 6)
238 | snowy.show(snowy.hstack([nearest, mitchell]))
239 |

Rotate and flip
242 |gibbons = snowy.load('gibbons.jpg')
243 | rotated = snowy.rotate(gibbons, 180)
244 | flipped = snowy.vflip(gibbons)
245 | triptych = snowy.hstack([gibbons, rotated, flipped],
246 | border_width=4, border_value=[0.5,0,0])
247 |

Cropping
250 |If you need to crop an image, just use numpy slicing.
251 |For example, this loads an OpenEXR image then crops out the top half:
252 |sunrise = snowy.load('sunrise.exr')
253 | cropped_sunrise = sunrise[:100,:,:]
254 | snowy.show(cropped_sunrise / 50.0) # darken the image
255 |

Alpha composition
258 |To copy a section of one image into another, simply use numpy slicing.
259 |However, to achieve "source-over" style alpha blending, using raw numpy math would be cumbersome. 260 | Snowy provides compose to make this easier:
261 |icon = snowy.load('snowflake.png')
262 | icon = snowy.resize(snowflake, height=100)
263 | sunset[:100,200:300] = snowy.compose(sunset[:100,200:300], icon)
264 | snowy.show(sunset)
265 |

Drop shadows
268 |Combining operations like blur and compose can be 269 | used to create a drop shadow:
270 |# Extend the 100x100 snowflake image on 4 sides to give room for blur.
271 | shadow = np.zeros([150, 150, 4])
272 | shadow[25:-25,25:-25,:] = icon
273 |
274 | # Invert the colors but not the alpha.
275 | white = shadow.copy()
276 | white[:,:,:3] = 1.0 - white[:,:,:3]
277 |
278 | # Blur the shadow, then "strengthen" it.
279 | shadow = snowy.blur(shadow, radius=10.0)
280 | shadow = snowy.compose(shadow, shadow)
281 | shadow = snowy.compose(shadow, shadow)
282 | shadow = snowy.compose(shadow, shadow)
283 |
284 | # Compose the white flake onto its shadow.
285 | dropshadow = snowy.compose(shadow, white)
286 |

Gradient noise
289 |Snowy's generate_noise
function generates a single-channel image whose values are
290 | in [-1, +1]. Here we create a square noise image that can be tiled horizontally:
n = snowy.generate_noise(100, 100, frequency=4, seed=42, wrapx=True)
292 | n = np.hstack([n, n])
293 | snowy.show(0.5 + 0.5 * n)
294 |

If you're interested in other types of noise, or if you need a super-fast noise generator, you might 297 | want to try pyfastnoisesimd.
298 |Distance fields
299 |This example uses generate_sdf
to create a signed distance field from a monochrome picture of two circles
300 | enclosed by a square. Note the usage of unitize
to adjust the values into the [0,1]
range.
circles = snowy.load('circles.png')
302 | sdf = snowy.unitize(snowy.generate_sdf(circles != 0.0))
303 | snowy.show(snowy.hstack([circles, sdf]))
304 |

Image generation
307 |Combining Snowy's unique features with numpy can be used to create interesting procedural images. 308 | The following example creates an elevation map for an imaginary island.
309 |def create_falloff(w, h, radius=0.4, cx=0.5, cy=0.5):
310 | hw, hh = 0.5 / w, 0.5 / h
311 | x = np.linspace(hw, 1 - hw, w)
312 | y = np.linspace(hh, 1 - hh, h)
313 | u, v = np.meshgrid(x, y, sparse=True)
314 | d2 = (u-cx)**2 + (v-cy)**2
315 | return 1-snowy.unitize(snowy.reshape(d2))
316 |
317 | def create_island(seed, freq=3.5):
318 | w, h = 750, 512
319 | falloff = create_falloff(w, h)
320 | n1 = 1.000 * snowy.generate_noise(w, h, freq*1, seed+0)
321 | n2 = 0.500 * snowy.generate_noise(w, h, freq*2, seed+1)
322 | n3 = 0.250 * snowy.generate_noise(w, h, freq*4, seed+2)
323 | n4 = 0.125 * snowy.generate_noise(w, h, freq*8, seed+3)
324 | elevation = falloff * (falloff / 2 + n1 + n2 + n3 + n4)
325 | mask = elevation < 0.4
326 | elevation = snowy.unitize(snowy.generate_sdf(mask))
327 | return (1 - mask) * np.power(elevation, 3.0)
328 |
329 | snowy.export(create_island(10), 'island.png')
330 |
Snowy also offers the compute_skylight and 333 | compute_normals functions to help with 3D rendering. These 334 | functions were used to create the following images.
335 |The first panel shows ambient occlusion generated by compute_skylight
, the second panel shows
337 | the normal map generated by compute_normals
, the right two panels use numpy to add diffuse
338 | lighting and a color gradient. The code for this is in
339 | test_lighting.py.
Wrap modes
341 |Snowy's blur, resize,
342 | generate_noise, and generate_sdf
343 | functions all take wrapx
and wrapy
arguments, both of which default to False
. These arguments
344 | tell Snowy how to sample from outside the boundaries of the source image or noise function.
To help understand these arguments, consider this tileable image and its 2x2 tiling:
346 |
348 |

Next, let's try blurring the tile naively:
350 |
See the seams? Now let's blur it with wrapx
and wrapy
set to True
when we call
352 | blur:

Wrappable gradient noise
355 |The wrap arguments are also useful for 2D noise. One way of making tileable gradient noise is to 356 | sample 3D noise on the surface of a cylinder, torus, or cube. However Snowy can do this more 357 | efficiently by generating 2D noise with modulus arithmetic.
358 |Here we created a 128x256 tile using generate_noise without the
359 | wrapx
argument, then horizontally tiled it twice:

Here's another tiling of gradient noise, but this time the tile was generated with wrapx
set to
362 | True
:

Wrappable distance fields
365 |Snowy's generate_sdf function also takes wrap arguments. For example 366 | here's a distance field in a 4x2 tiling:
367 |
Here's the same distance field, this time with wrapx and wrapy set to True
:

Drawing quads
371 |Snowy can also rasterize convex textured polygons. For example:
372 |verts = np.array([
373 | [-0.67, 0.38, 3.70, 0., 0. ],
374 | [-0.10, 0.38, 2.57, 1., 0. ],
375 | [-0.10, -0.96, 2.57, 1., 1. ],
376 | [-0.67, -0.96, 3.70, 0., 1. ]])
377 | texture = snowy.load('texture.png')
378 | background = np.full((1080, 1920, 4), (0.54, 0.54, 0.78, 1.00))
379 | snowy.draw_polygon(background, texture, verts)
380 | snowy.show(background)
381 |

Each vertex is specified as a 5-tuple to enable perspective-correct 384 | interpolation. For more information see 385 | draw_polygon.
386 |Interop
387 |Snowy's algorithms require images to be row-major three-dimensional float64
numpy arrays, with
388 | color channels living in the trailing dimension. If you're working with another module that does not
389 | follow this convention, consider using one of the following interop functions.
-
391 |
- To add or remove the trailing 1 from the shape of grayscale images, use 392 | reshape and unshape. 393 |
- To swap color channels in or out of the leading dimension, use 394 | to_planar and from_planar. 395 |
- To cast between
float64
and other types, just use numpy. For example, 396 |np.uint8(myimg * 255)
ornp.float64(myimg) / 255
.
397 | - To swap rows with columns, use numpy's 398 | swapaxes function. 399 |










221 |









alpha_view = myimage[:,:,3]
.
160 | """
161 | assert len(image.shape) == 3 and image.shape[2] == 4
162 | return np.dsplit(image, 4)[3].copy()
163 |
164 | def extract_rgb(image: np.ndarray) -> np.ndarray:
165 | """Extract the RGB planes from an RGBA image.
166 |
167 | Note that this returns a copy. If you wish to obtain a view that
168 | allows mutating pixels, simply use slicing instead. For
169 | example, to invert the colors of an image while leaving alpha
170 | intact, you can do:
171 | myimage[:,:,:3] = 1.0 - myimage[:,:,:3]
.
172 | """
173 | assert len(image.shape) == 3 and image.shape[2] >= 3
174 | planes = np.dsplit(image, image.shape[2])
175 | return np.dstack(planes[:3])
176 |
177 | def to_planar(image: np.ndarray) -> np.ndarray:
178 | """Convert a row-major image into a channel-major image.
179 |
180 | This creates a copy, not a view.
181 | """
182 | assert len(image.shape) == 3
183 | result = np.array(np.dsplit(image, image.shape[2]))
184 | return np.reshape(result, result.shape[:-1])
185 |
186 | def from_planar(image: np.ndarray) -> np.ndarray:
187 | """Create a channel-major image into row-major image.
188 |
189 | This creates a copy, not a view.
190 | """
191 | assert len(image.shape) == 3
192 | return np.dstack(image)
193 |
--------------------------------------------------------------------------------
/snowy/lighting.py:
--------------------------------------------------------------------------------
1 | from . import io
2 | from numba import prange, jit
3 | import math
4 | import numpy as np
5 |
6 | SWEEP_DIRECTIONS = np.int16([
7 | (1, 0), (0, 1), (-1, 0), (0, -1), # Rook
8 | (1, 1), (-1, -1), (1, -1), (-1, 1), # Bishop
9 | (2, 1), (2, -1), (-2, 1), (-2, -1), # Knight
10 | (1, 2), (1, -2), (-1, 2), (-1, -2) # Knight
11 | ])
12 |
13 | def compute_skylight(elevation, verbose=False):
14 | """Compute ambient occlusion from a height map."""
15 | height, width, nchan = elevation.shape
16 | assert nchan == 1
17 | result = np.zeros([height, width])
18 | _compute_skylight(result, elevation[:,:,0], verbose)
19 | return io.reshape(np.clip(1.0 - result, 0, 1))
20 |
21 | def compute_normals(elevation):
22 | """Generate a 3-channel normal map from a height map.
23 |
24 | The normal components are in the range [-1,+1] and the size of the
25 | normal map is (width-1, height-1) due to forward differencing.
26 | """
27 | height, width, nchan = elevation.shape
28 | assert nchan == 1
29 | normals = np.empty([height - 1, width - 1, 3])
30 | _compute_normals(elevation[:,:,0], normals)
31 | return normals
32 |
33 | @jit(nopython=True, fastmath=True, cache=True)
34 | def _compute_normals(el, normals):
35 | h, w = normals.shape[:2]
36 | for row in range(h):
37 | for col in range(w):
38 | p = np.float64((col / w, row / h, el[row][col]))
39 | dx = np.float64(((col+1) / w, row / h, el[row][col+1]))
40 | dy = np.float64((col / w, (row+1) / h, el[row+1][col]))
41 | v1 = dx - p
42 | v2 = dy - p
43 | n = np.float64((
44 | (v1[1] * v2[2]) - (v1[2] * v2[1]),
45 | (v1[2] * v2[0]) - (v1[0] * v2[2]),
46 | (v1[0] * v2[1]) - (v1[1] * v2[0])))
47 | isq = 1 / np.linalg.norm(n)
48 | normals[row][col] = n * isq
49 |
50 | def _compute_skylight(dst, src, verbose):
51 | height, width = src.shape
52 | cnt = np.zeros(dst.shape, dtype='u8')
53 |
54 | # TODO Fix allocation or explain the "3"
55 | seedpoints = np.empty([3 * max(width, height), 2], dtype='i2')
56 | maxpathlen = max(width, height) + 1
57 |
58 | for direction in SWEEP_DIRECTIONS:
59 | nsweeps = _generate_seedpoints(src, direction, seedpoints)
60 | if verbose:
61 | print('Horizon direction: ', direction)
62 | sweeps = np.empty([nsweeps, maxpathlen, 3])
63 | pts = np.empty([nsweeps, 3])
64 | _horizon_scan(src, dst, cnt, direction, seedpoints, sweeps, pts)
65 |
66 | dst /= cnt
67 | dst *= 4 / np.pi
68 |
69 | # TODO This function needs to be rewritten or documented.
70 | def _generate_seedpoints(field, direction, seedpoints):
71 | h, w = field.shape[:2]
72 | s = 0
73 | sx, sy = np.sign(direction)
74 | ax, ay = np.abs(direction)
75 | nsweeps = ay * w + ax * h - (ax + ay - 1)
76 | for x in range(-ax, w - ax):
77 | for y in range(-ay, h - ay):
78 | if x >= 0 and x < w and y >= 0 and y < h: continue
79 | px, py = x, y
80 | if sx < 0: px = w - x - 1
81 | if sy < 0: py = h - y - 1
82 | seedpoints[s][0] = px
83 | seedpoints[s][1] = py
84 | s += 1
85 | assert nsweeps == s
86 | return nsweeps
87 |
88 | SIG0 = "void(f8[:,:],f8[:,:],u8[:,:],i2[:],i2[:,:],f8[:,:,:],f8[:,:])"
89 | @jit([SIG0], nopython=True, fastmath=True, parallel=True)
90 | def _horizon_scan(heights, occlusion, counts, direction, seedpoints,
91 | sweeps, pts):
92 | h, w = heights.shape[:2]
93 | cellw = 1 / max(w, h)
94 | cellh = 1 / max(w, h)
95 | nsweeps = len(sweeps)
96 | for sweep in prange(nsweeps):
97 | thispt = pts[sweep]
98 | stack = sweeps[sweep]
99 | startpt = seedpoints[sweep]
100 | pathlen = 0
101 | i, j = startpt
102 | ii, jj = min(max(0, i), w-1), min(max(0, j), h-1)
103 |
104 | thispt[0] = i * cellw
105 | thispt[1] = j * cellh
106 | thispt[2] = heights[jj][ii]
107 |
108 | stack_top = 0
109 |
110 | stack[stack_top] = thispt
111 |
112 | i += direction[0]
113 | j += direction[1]
114 | while i >= 0 and i < w and j >= 0 and j < h:
115 |
116 | thispt[0] = i * cellw
117 | thispt[1] = j * cellh
118 | thispt[2] = heights[j][i]
119 |
120 | while stack_top > 0:
121 |
122 | a, b = thispt, stack[stack_top]
123 | dx = b[0] - a[0]
124 | dy = b[1] - a[1]
125 | y = b[2] - a[2]
126 | x = math.sqrt(dx * dx + dy * dy)
127 | s1 = y / x
128 |
129 | a, b = thispt, stack[stack_top - 1]
130 | dx = b[0] - a[0]
131 | dy = b[1] - a[1]
132 | y = b[2] - a[2]
133 | x = math.sqrt(dx * dx + dy * dy)
134 | s2 = y / x
135 |
136 | if s1 >= s2: break
137 | stack_top -= 1
138 |
139 | horizonpt = stack[stack_top]
140 | stack_top += 1
141 | stack[stack_top] = thispt
142 |
143 | d = horizonpt - thispt
144 | dx = d[2] / np.linalg.norm(d)
145 | occlusion[j][i] += math.atan(max(dx, 0))
146 | counts[j][i] += 1
147 |
148 | i += direction[0]
149 | j += direction[1]
150 |
--------------------------------------------------------------------------------
/snowy/noise.py:
--------------------------------------------------------------------------------
1 | import numpy as np
2 | import math
3 | from . import io
4 |
5 | def generate_noise(width, height, frequency, seed=1, wrapx=False,
6 | wrapy=False, offset=[0,0]):
7 | """Generate a single-channel gradient noise image.
8 |
9 | A frequency of 1.0 creates a single surflet across the width of the
10 | image, while a frequency of 4.0 creates a 4x4 grid such that the
11 | (2,2) surflet is centered. Noise values live within the [-1,+1]
12 | range.
13 | """
14 | return _noise(width, height, frequency, seed, wrapx, wrapy, offset)
15 |
16 | def generate_fBm(width, height, freq, layers, seed, lacunarity=2,
17 | persistence=2, wrapx=False, wrapy=False):
18 | """Generate 2D fractional brownian motion by adding layers of noise.
19 |
20 | See also generate_noise.
21 | """
22 | noise = generate_noise
23 | n = np.zeros([height, width, 1])
24 | amplitude = 1
25 | for f in range(layers):
26 | lseed = seed + int(f)
27 | n += amplitude * noise(width, height, freq, lseed, wrapx, wrapy)
28 | freq *= lacunarity
29 | amplitude /= persistence
30 | return n
31 |
32 | def _noise(width, height, frequency, seed, wrapx, wrapy, offset):
33 | nrows, ncols = int(height), int(width)
34 | table = Noise(seed)
35 |
36 | # Compute the span of U texcoords in [0,+1] such that 0 is at the
37 | # left edge of the left-most texel, and +1 is at the right edge of
38 | # the right-most pixel.
39 | maxx = frequency
40 | hw = 0.5 * maxx / width
41 | u = np.linspace(hw, maxx - hw, ncols) + offset[0]
42 |
43 | # Compute the span of V texcoords according to the aspect ratio.
44 | maxy = frequency * float(height) / width
45 | hh = 0.5 * maxy / height
46 | v = np.linspace(hh, maxy - hh, nrows) + offset[1]
47 |
48 | # Generate floating point texture coordinates, then split them into
49 | # integer and fractional components.
50 | u, v = np.meshgrid(u, v, sparse=True)
51 | i0, j0 = np.floor(u).astype(int), np.floor(v).astype(int)
52 | i1, j1 = i0 + 1, j0 + 1
53 | x0, y0 = u - i0, v - j0
54 | x1, y1 = x0 - 1, y0 - 1
55 |
56 | # Find the 2D vectors at the nearest grid cell corners.
57 | if wrapx:
58 | assert math.modf(frequency)[0] == 0.0, \
59 | "wrapx requires an integer frequency"
60 | i0 = i0 % int(frequency)
61 | i1 = i1 % int(frequency)
62 | if wrapy:
63 | assert math.modf(maxy)[0] == 0.0, \
64 | "wrapy requires frequency*width/height to be an integer"
65 | j0 = j0 % int(maxy)
66 | j1 = j1 % int(maxy)
67 | grad00 = _gradient(table, i0, j0)
68 | grad01 = _gradient(table, i0, j1)
69 | grad10 = _gradient(table, i1, j0)
70 | grad11 = _gradient(table, i1, j1)
71 |
72 | va = dot(x0, y0, grad00[0], grad00[1])
73 | vb = dot(x1, y0, grad10[0], grad10[1])
74 | vc = dot(x0, y1, grad01[0], grad01[1])
75 | vd = dot(x1, y1, grad11[0], grad11[1])
76 |
77 | # Lerp the neighboring 4 surflets
78 | t0 = x0*x0*x0*(x0*(x0*6.0 - 15.0) + 10.0)
79 | t1 = y0*y0*y0*(y0*(y0*6.0 - 15.0) + 10.0)
80 | result = va + t0 * (vb-va) + t1 * (vc-va) + t0 * t1 * (va-vb-vc+vd)
81 | return io.reshape(result)
82 |
83 | class Noise:
84 | def __init__(self, seed):
85 | self.rnd = np.random.RandomState(seed)
86 | self.size = 256
87 | self.mask = int(self.size - 1)
88 | self.indices = np.arange(self.size, dtype = np.int16)
89 | self.rnd.shuffle(self.indices)
90 | theta = np.linspace(0, math.tau, self.size, endpoint=False)
91 | self.gradients = [np.cos(theta), np.sin(theta)]
92 |
93 | def _gradient(table: Noise, i, j):
94 | perm, mask = table.indices, table.mask
95 | u, v = table.gradients
96 | hash = perm[np.bitwise_and(perm[np.bitwise_and(i, mask)] + j, mask)]
97 | return u[hash], v[hash]
98 |
99 | def dot(x, y, gradx, grady):
100 | return gradx * x + grady * y
101 |
--------------------------------------------------------------------------------
/snowy/ops.py:
--------------------------------------------------------------------------------
1 | """Define add_border etc."""
2 |
3 | from snowy.io import *
4 | from numba import guvectorize
5 | import numpy as np
6 |
7 | def add_left(image: np.ndarray, T=2, V=0) -> np.ndarray:
8 | height, width, nchan = image.shape
9 | newshape = height, width + T, nchan
10 | result = np.full(newshape, np.float64(V))
11 | np.copyto(result[:,T:], image)
12 | return result
13 |
14 | def add_right(image: np.ndarray, T=2, V=0) -> np.ndarray:
15 | height, width, nchan = image.shape
16 | newshape = height, width + T, nchan
17 | result = np.full(newshape, np.float64(V))
18 | np.copyto(result[:,:-T], image)
19 | return result
20 |
21 | def add_top(image: np.ndarray, T=2, V=0) -> np.ndarray:
22 | height, width, nchan = image.shape
23 | newshape = height + T, width, nchan
24 | result = np.full(newshape, np.float64(V))
25 | np.copyto(result[T:,:], image)
26 | return result
27 |
28 | def add_bottom(image: np.ndarray, T=2, V=0) -> np.ndarray:
29 | height, width, nchan = image.shape
30 | newshape = height + T, width, nchan
31 | result = np.full(newshape, np.float64(V))
32 | np.copyto(result[:-T,:], image)
33 | return result
34 |
35 | def add_border(image: np.ndarray, width=2, value=0, sides='ltrb'):
36 | """Extend the size of an image by adding borders.
37 |
38 |
39 | The sides
argument defaults to
40 | "LTRB"
, which enables borders for all four sides: Left,
41 | Top, Right, and Bottom. This can be used to select which borders you
42 | wish to add.
43 |
hstack
except that it adds
58 | a border around each image. The borders can be controlled
59 | with the optional border_width
and
60 | border_value
arguments. See also
61 | vstack.
62 | """
63 | if border_width == 0: return np.hstack(images)
64 | T, V = border_width, border_value
65 | result = []
66 | for image in images[:-1]:
67 | result.append(add_border(image, T, V, 'LTB'))
68 | result.append(add_border(images[-1], T, V))
69 | return np.hstack(result)
70 |
71 | def vstack(images, border_width=2, border_value=0):
72 | """Vertically concatenate a list of images with a border.
73 |
74 | This is similar to numpy's vstack
except that it adds
75 | a border around each image. The borders can be controlled
76 | with the optional border_width
and
77 | border_value
arguments. See also
78 | hstack.
79 | """
80 | if border_width == 0: return np.vstack(images)
81 | T, V = border_width, border_value
82 | result = []
83 | for image in images[:-1]:
84 | result.append(add_border(image, T, V, 'LTR'))
85 | result.append(add_border(images[-1], T, V))
86 | return np.vstack(result)
87 |
88 | def unitize(img):
89 | """Remap the values so that they span the range from 0 to +1."""
90 | return (img - np.amin(img)) / (np.amax(img) - np.amin(img))
91 |
92 | def gradient(img):
93 | """Compute X derivatives and Y derivatives."""
94 | nx, ny = np.gradient(unshape(img))
95 | return reshape(nx), reshape(ny)
96 |
97 | def rotate(source: np.ndarray, degrees) -> np.ndarray:
98 | """Rotate image counter-clockwise by a multiple of 90 degrees."""
99 | assert len(source.shape) == 3, 'Shape is not rows x cols x channels'
100 | assert source.dtype == np.float, 'Images must be doubles.'
101 | h, w, c = source.shape
102 | degrees %= 360
103 | if degrees == 90:
104 | result = np.empty([w, h, c])
105 | rotate90(result, source)
106 | elif degrees == 180:
107 | result = np.empty([h, w, c])
108 | rotate180(result, source)
109 | elif degrees == 270:
110 | result = np.empty([w, h, c])
111 | rotate270(result, source)
112 | else:
113 | assert False, 'Angle must be a multiple of 90.'
114 | return result
115 |
116 | def hflip(source: np.ndarray) -> np.ndarray:
117 | """Horizontally mirror the given image."""
118 | assert len(source.shape) == 3, 'Shape is not rows x cols x channels'
119 | assert source.dtype == np.float, 'Images must be doubles.'
120 | h, w, c = source.shape
121 | result = np.empty([h, w, c])
122 | jit_hflip(result, source)
123 | return result
124 |
125 | def vflip(source: np.ndarray) -> np.ndarray:
126 | """Vertically mirror the given image."""
127 | assert len(source.shape) == 3, 'Shape is not rows x cols x channels'
128 | assert source.dtype == np.float, 'Images must be doubles.'
129 | h, w, c = source.shape
130 | result = np.empty([h, w, c])
131 | jit_vflip(result, source)
132 | return result
133 |
134 | def compose(dst: np.ndarray, src: np.ndarray) -> np.ndarray:
135 | """Compose a source image with alpha onto a destination image."""
136 | a, b = ensure_alpha(src), ensure_alpha(dst)
137 | alpha = extract_alpha(a)
138 | result = b * (1.0 - alpha) + a * alpha
139 | if dst.shape[2] == 3:
140 | return extract_rgb(result)
141 | return result
142 |
143 | def compose_premultiplied(dst: np.ndarray, src: np.ndarray):
144 | """Draw an image with premultiplied alpha over the destination."""
145 | a, b = ensure_alpha(src), ensure_alpha(dst)
146 | alpha = extract_alpha(a)
147 | result = b * (1.0 - alpha) + a
148 | if dst.shape[2] == 3:
149 | return extract_rgb(result)
150 | return result
151 |
152 | SIG0 = "void(f8[:,:,:], f8[:,:,:])"
153 | SIG1 = "(r,c,d),(c,r,d)"
154 | @guvectorize([SIG0], SIG1, target='parallel', cache=True)
155 | def rotate90(result, source):
156 | nrows, ncols, nchan = source.shape
157 | for row in range(nrows):
158 | for col in range(ncols):
159 | for chan in range(nchan):
160 | v = source[row][col][chan]
161 | result[-col-1][row][chan] = v
162 |
163 | SIG0 = "void(f8[:,:,:], f8[:,:,:])"
164 | SIG1 = "(r,c,d),(r,c,d)"
165 | @guvectorize([SIG0], SIG1, target='parallel', cache=True)
166 | def rotate180(result, source):
167 | nrows, ncols, nchan = source.shape
168 | for row in range(nrows):
169 | for col in range(ncols):
170 | for chan in range(nchan):
171 | v = source[row][col][chan]
172 | result[-row-1][-col-1][chan] = v
173 |
174 | SIG0 = "void(f8[:,:,:], f8[:,:,:])"
175 | SIG1 = "(r,c,d),(c,r,d)"
176 | @guvectorize([SIG0], SIG1, target='parallel', cache=True)
177 | def rotate270(result, source):
178 | nrows, ncols, nchan = source.shape
179 | for row in range(nrows):
180 | for col in range(ncols):
181 | for chan in range(nchan):
182 | v = source[row][col][chan]
183 | result[col][-row-1][chan] = v
184 |
185 | SIG0 = "void(f8[:,:,:], f8[:,:,:])"
186 | SIG1 = "(r,c,d),(r,c,d)"
187 | @guvectorize([SIG0], SIG1, target='parallel', cache=True)
188 | def jit_hflip(result, source):
189 | nrows, ncols, nchan = source.shape
190 | for row in range(nrows):
191 | for col in range(ncols):
192 | for chan in range(nchan):
193 | v = source[row][col][chan]
194 | result[row][-col-1][chan] = v
195 |
196 | SIG0 = "void(f8[:,:,:], f8[:,:,:])"
197 | SIG1 = "(r,c,d),(r,c,d)"
198 | @guvectorize([SIG0], SIG1, target='parallel', cache=True)
199 | def jit_vflip(result, source):
200 | nrows, ncols, nchan = source.shape
201 | for row in range(nrows):
202 | for col in range(ncols):
203 | for chan in range(nchan):
204 | v = source[row][col][chan]
205 | result[-row-1][col][chan] = v
206 |
--------------------------------------------------------------------------------
/tests/demo.py:
--------------------------------------------------------------------------------
1 | # 1. Create falloff shape.
2 |
3 | import snowy
4 | import numpy as np
5 | from functools import reduce
6 | from scipy import interpolate
7 |
8 | width, height = 768, 256
9 | x, y = np.linspace(-1, 1, width), np.linspace(-1, 1, height)
10 | u, v = np.meshgrid(x, y, sparse=True)
11 | falloff = np.clip(1 - (u * u + v * v), 0, 1)
12 | falloff = snowy.reshape(falloff / 2)
13 | snowy.show(falloff)
14 |
15 | # 2. Add layers of gradient noise and scale with falloff.
16 |
17 | noise = snowy.generate_noise
18 | noise = [noise(width, height, 6 * 2**f, int(f)) * 1/2**f for f in range(4)]
19 | noise = reduce(lambda x, y: x+y, noise)
20 | elevation = falloff * (falloff / 2 + noise)
21 | elevation = snowy.generate_udf(elevation < 0.1)
22 | elevation /= np.amax(elevation)
23 | snowy.show(elevation)
24 |
25 | # 3. Compute ambient occlusion.
26 |
27 | occlusion = snowy.compute_skylight(elevation)
28 | snowy.show(occlusion)
29 |
30 | # 4. Generate normal map.
31 |
32 | normals = snowy.resize(snowy.compute_normals(elevation), width, height)
33 | snowy.show(0.5 + 0.5 * normals)
34 |
35 | # 5. Apply harsh diffuse lighting.
36 |
37 | lightdir = np.float64([0.2, -0.2, 1])
38 | lightdir /= np.linalg.norm(lightdir)
39 | lambert = np.sum(normals * lightdir, 2)
40 | snowy.show(snowy.reshape(lambert) * occlusion)
41 |
42 | # 6. Lighten the occlusion, flatten the normals, and re-light.
43 |
44 | occlusion = 0.5 + 0.5 * occlusion
45 | normals += np.float64([0,0,0.5])
46 | normals /= snowy.reshape(np.sqrt(np.sum(normals * normals, 2)))
47 | lambert = np.sum(normals * lightdir, 2)
48 | lighting = snowy.reshape(lambert) * occlusion
49 | snowy.show(lighting)
50 |
51 | # 7. Apply color gradient.
52 |
53 | xvals = np.arange(256)
54 | yvals = snowy.load('tests/terrain.png')[0,:,:3]
55 | apply_lut = interpolate.interp1d(xvals, yvals, axis=0)
56 | el = elevation * 0.2 + 0.49
57 | el = np.clip(255 * el, 0, 255)
58 | albedo = apply_lut(snowy.unshape(el))
59 | snowy.show(albedo * lighting)
60 |
--------------------------------------------------------------------------------
/tests/gamma_dalai_lama_gray.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/tests/gamma_dalai_lama_gray.jpg
--------------------------------------------------------------------------------
/tests/islands.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/tests/islands.png
--------------------------------------------------------------------------------
/tests/overlay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/tests/overlay.png
--------------------------------------------------------------------------------
/tests/performance.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | """
4 | Snowy implements high-quality filtering and is written purely in Python.
5 | We do not expect it to be quite as fast as PIL or vips. However we do
6 | ensure that it performs reasonably with large images, which it achieves
7 | through careful usage of numba.
8 | """
9 |
10 | import sys
11 | sys.path.append('../snowy')
12 |
13 | import timeit
14 | import snowy
15 | from PIL import Image
16 | import numpy as np
17 |
18 | global imgarray
19 | global pilimage
20 |
21 | ZOOM = 16
22 |
23 | def minify_with_pil():
24 | global imgarray
25 | global pilimage
26 | height, width = imgarray.shape[:2]
27 | print(imgarray.shape, imgarray.dtype)
28 | pilimage = pilimage.resize((width//ZOOM, height//ZOOM))
29 | # pilimage.show()
30 |
31 | def minify_with_snowy():
32 | global imgarray
33 | global pilimage
34 | print(imgarray.shape, imgarray.dtype)
35 | height, width = imgarray.shape[:2]
36 | imgarray = snowy.resize(imgarray, width//ZOOM, height//ZOOM)
37 | # snowy.show(imgarray)
38 |
39 | def setup(grayscale=False, imgfile='~/Desktop/SaltLakes.jpg'):
40 | print('Loading image...')
41 | global imgarray
42 | global pilimage
43 | imgarray = snowy.load(imgfile)
44 | if grayscale:
45 | assert imgarray.shape[2] == 3, "Not an RGB image."
46 | r,g,b = np.split(imgarray, 3, axis=2)
47 | imgarray = r
48 | pilimage = Image.fromarray(np.uint8(snowy.unshape(imgarray)))
49 |
50 | seconds = timeit.timeit('minify_with_pil()', setup='setup()',
51 | globals=globals(), number=1)
52 | print(f"PIL minification took {seconds:6.3} seconds")
53 |
54 | seconds = timeit.timeit('minify_with_snowy()', setup='setup()',
55 | globals=globals(), number=1)
56 | print(f"Snowy minification took {seconds:6.3} seconds")
57 |
--------------------------------------------------------------------------------
/tests/small_dalai_lama.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/tests/small_dalai_lama.png
--------------------------------------------------------------------------------
/tests/sobel_input.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/tests/sobel_input.png
--------------------------------------------------------------------------------
/tests/terrain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/prideout/snowy/995c373bd751daf35d8b9a851de7a744329552d7/tests/terrain.png
--------------------------------------------------------------------------------
/tests/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
20 |
21 |
22 |
23 |