├── filmgrainer ├── __init__.py ├── graingen.py ├── graingamma.py ├── filmgrainer.py └── main.py ├── .gitignore ├── setup.py ├── examples ├── dias.png ├── bw_neg.png ├── input.jpg ├── trashed.png ├── color_neg.png ├── trashed_bw.png └── generate.sh ├── pyproject.toml ├── setup.cfg ├── LICENSE └── README.md /filmgrainer/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = "1.0.2" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *__pycache__/ 2 | *.venv/ 3 | build/* 4 | ignore/* 5 | filmgrainer.egg-info/* 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /examples/dias.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larspontoppidan/filmgrainer/HEAD/examples/dias.png -------------------------------------------------------------------------------- /examples/bw_neg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larspontoppidan/filmgrainer/HEAD/examples/bw_neg.png -------------------------------------------------------------------------------- /examples/input.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larspontoppidan/filmgrainer/HEAD/examples/input.jpg -------------------------------------------------------------------------------- /examples/trashed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larspontoppidan/filmgrainer/HEAD/examples/trashed.png -------------------------------------------------------------------------------- /examples/color_neg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larspontoppidan/filmgrainer/HEAD/examples/color_neg.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /examples/trashed_bw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larspontoppidan/filmgrainer/HEAD/examples/trashed_bw.png -------------------------------------------------------------------------------- /examples/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo Generating examples ... 4 | echo 5 | 6 | filmgrainer --gray --type 3 --power 0.8,0.2,0.1 -o bw_neg.png input.jpg 7 | filmgrainer --type 4 --sat 0.8 --power 1,0.2,0.2 -o color_neg.png input.jpg 8 | filmgrainer --type 1 --sat 0.6 --power 0.75,0.1,0.1 -o dias.png input.jpg 9 | filmgrainer --type 1 --gray --power 1,0.3,0.2 --scale 3 --sharpen 1 -o trashed_bw.png input.jpg 10 | filmgrainer --type 1 --sat 0.8 --power 1,0.3,0.2 --scale 3 --sharpen 1 -o trashed.png input.jpg 11 | 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = filmgrainer 3 | version = attr: filmgrainer.__version__ 4 | author = Lars Ole Pontoppidan 5 | author_email = contact@larsee.com 6 | description = filmgrainer - Adding realistic film grain to pictures 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | license = MIT 10 | classifiers = 11 | License :: OSI Approved :: MIT License 12 | Programming Language :: Python :: 3 13 | 14 | [options] 15 | packages = 16 | filmgrainer 17 | python_requires = >=3.6 18 | install_requires = 19 | pillow >=9.2.0 20 | numpy >=1.23.0 21 | 22 | [options.entry_points] 23 | console_scripts = 24 | filmgrainer = filmgrainer.main:main 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lars Ole Pontoppidan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Filmgrainer is an image processing algorithm that adds noise to an image resembling photographic film grain. It's implemented in Python and runs as a command line utility on Linux platforms, installable with pip. 3 | 4 | For further description and more examples see this [blog post](https://larsee.com/blog/2022/11/introducing-filmgrainer/). 5 | 6 | ## Installation 7 | 8 | Filmgrainer can be installed directly from this repository with pip: 9 | 10 | ```text 11 | pip install git+https://github.com/larspontoppidan/filmgrainer 12 | ``` 13 | 14 | The installation pulls in numpy and pillow. Consider installing in a virtual environment. 15 | 16 | ## Examples 17 | 18 | #### Input image: 19 | ![Input image](examples/input.jpg) 20 | 21 | #### Transformed to a black and white photograph: 22 | `filmgrainer --gray --type 3 --power 0.8,0.2,0.1 -o bw_neg.png input.jpg` 23 | 24 | ![Coarse black and white look](examples/bw_neg.png) 25 | 26 | #### or a grainy color negative: 27 | `filmgrainer --type 4 --sat 0.8 --power 1,0.2,0.2 -o color_neg.png input.jpg` 28 | 29 | ![Grained color negative look](examples/color_neg.png) 30 | 31 | #### With just a gentle amount of dias-film like grain: 32 | `filmgrainer --type 1 --sat 0.6 --power 0.75,0.1,0.1 -o dias.png input.jpg` 33 | 34 | ![Gentle dias-film like grain](examples/dias.png) 35 | 36 | #### Totally trashed by grain using the scale feature and sharpen: 37 | `filmgrainer --type 1 --gray --power 1,0.3,0.2 --scale 3 --sharpen 1 -o trashed_bw.png input.jpg` 38 | 39 | ![Totally trashed by grain, black and white](examples/trashed_bw.png) 40 | 41 | `filmgrainer --type 1 --sat 0.8 --power 1,0.3,0.2 --scale 3 --sharpen 1 -o trashed.png input.jpg` 42 | 43 | ![Totally trashed by grain](examples/trashed.png) 44 | -------------------------------------------------------------------------------- /filmgrainer/graingen.py: -------------------------------------------------------------------------------- 1 | from PIL import Image 2 | import random 3 | import numpy as np 4 | 5 | def _makeGrayNoise(width, height, power): 6 | buffer = np.zeros([height, width], dtype=int) 7 | 8 | for y in range(0, height): 9 | for x in range(0, width): 10 | buffer[y, x] = random.gauss(128, power) 11 | buffer = buffer.clip(0, 255) 12 | return Image.fromarray(buffer.astype(dtype=np.uint8)) 13 | 14 | def _makeRgbNoise(width, height, power, saturation): 15 | buffer = np.zeros([height, width, 3], dtype=int) 16 | intens_power = power * (1.0 - saturation) 17 | for y in range(0, height): 18 | for x in range(0, width): 19 | intens = random.gauss(128, intens_power) 20 | buffer[y, x, 0] = random.gauss(0, power) * saturation + intens 21 | buffer[y, x, 1] = random.gauss(0, power) * saturation + intens 22 | buffer[y, x, 2] = random.gauss(0, power) * saturation + intens 23 | 24 | buffer = buffer.clip(0, 255) 25 | return Image.fromarray(buffer.astype(dtype=np.uint8)) 26 | 27 | 28 | def grainGen(width, height, grain_size, power, saturation, seed = 1): 29 | # A grain_size of 1 means the noise buffer will be made 1:1 30 | # A grain_size of 2 means the noise buffer will be resampled 1:2 31 | noise_width = int(width / grain_size) 32 | noise_height = int(height / grain_size) 33 | random.seed(seed) 34 | 35 | if saturation < 0.0: 36 | print("Making B/W grain, width: %d, height: %d, grain-size: %s, power: %s, seed: %d" % ( 37 | noise_width, noise_height, str(grain_size), str(power), seed)) 38 | img = _makeGrayNoise(noise_width, noise_height, power) 39 | else: 40 | print("Making RGB grain, width: %d, height: %d, saturation: %s, grain-size: %s, power: %s, seed: %d" % ( 41 | noise_width, noise_height, str(saturation), str(grain_size), str(power), seed)) 42 | img = _makeRgbNoise(noise_width, noise_height, power, saturation) 43 | 44 | # Resample 45 | if grain_size != 1.0: 46 | img = img.resize((width, height), resample = Image.LANCZOS) 47 | 48 | return img 49 | 50 | 51 | if __name__ == "__main__": 52 | import sys 53 | if len(sys.argv) == 8: 54 | width = int(sys.argv[2]) 55 | height = int(sys.argv[3]) 56 | grain_size = float(sys.argv[4]) 57 | power = float(sys.argv[5]) 58 | sat = float(sys.argv[6]) 59 | seed = int(sys.argv[7]) 60 | out = grainGen(width, height, grain_size, power, sat, seed) 61 | out.save(sys.argv[1]) 62 | 63 | -------------------------------------------------------------------------------- /filmgrainer/graingamma.py: -------------------------------------------------------------------------------- 1 | 2 | import numpy as np 3 | 4 | _ShadowEnd = 160 5 | _HighlightStart = 200 6 | 7 | 8 | def _gammaCurve(gamma, x): 9 | """ Returns from 0.0 to 1.0""" 10 | return pow((x / 255.0), (1.0 / gamma)) 11 | 12 | 13 | def _calcDevelopment(shadow_level, high_level, x): 14 | """ 15 | This function returns a development like this: 16 | 17 | (return) 18 | ^ 19 | | 20 | 0.5 | o - o <-- mids level, always 0.5 21 | | - - 22 | | - - 23 | | - o <-- high_level eg. 0.25 24 | | - 25 | | o <-- shadow_level eg. 0.15 26 | | 27 | 0 -+-----------------|-------|------------|-----> x (input) 28 | 0 160 200 255 29 | """ 30 | if x < _ShadowEnd: 31 | power = 0.5 - (_ShadowEnd - x) * (0.5 - shadow_level) / _ShadowEnd 32 | elif x < _HighlightStart: 33 | power = 0.5 34 | else: 35 | power = 0.5 - (x - _HighlightStart) * (0.5 - high_level) / (255 - _HighlightStart) 36 | 37 | return power 38 | 39 | class Map: 40 | def __init__(self, map): 41 | self.map = map 42 | 43 | @staticmethod 44 | def calculate(src_gamma, noise_power, shadow_level, high_level) -> 'Map': 45 | map = np.zeros([256, 256], dtype=np.uint8) 46 | 47 | # We need to level off top end and low end to leave room for the noise to breathe 48 | crop_top = noise_power * high_level / 12 49 | crop_low = noise_power * shadow_level / 20 50 | 51 | pic_scale = 1 - (crop_top + crop_low) 52 | pic_offs = 255 * crop_low 53 | 54 | for src_value in range(0, 256): 55 | # Gamma compensate picture source value itself 56 | pic_value = _gammaCurve(src_gamma, src_value) * 255.0 57 | 58 | # In the shadows we want noise gamma to be 0.5, in the highs, 2.0: 59 | gamma = pic_value * (1.5 / 256) + 0.5 60 | gamma_offset = _gammaCurve(gamma, 128) 61 | 62 | # Power is determined by the development 63 | power = _calcDevelopment(shadow_level, high_level, pic_value) 64 | 65 | for noise_value in range(0, 256): 66 | gamma_compensated = _gammaCurve(gamma, noise_value) - gamma_offset 67 | value = pic_value * pic_scale + pic_offs + 255.0 * power * noise_power * gamma_compensated 68 | if value < 0: 69 | value = 0 70 | elif value < 255.0: 71 | value = int(value) 72 | else: 73 | value = 255 74 | map[src_value, noise_value] = value 75 | 76 | return Map(map) 77 | 78 | def lookup(self, pic_value, noise_value): 79 | return self.map[pic_value, noise_value] 80 | 81 | def saveToFile(self, filename): 82 | from PIL import Image 83 | img = Image.fromarray(self.map) 84 | img.save(filename) 85 | 86 | if __name__ == "__main__": 87 | import matplotlib.pyplot as plt 88 | import numpy as np 89 | 90 | def plotfunc(x_min, x_max, step, func): 91 | x_all = np.arange(x_min, x_max, step) 92 | y = [] 93 | for x in x_all: 94 | y.append(func(x)) 95 | 96 | plt.figure() 97 | plt.plot(x_all, y) 98 | plt.grid() 99 | 100 | def development1(x): 101 | return _calcDevelopment(0.2, 0.3, x) 102 | 103 | def gamma05(x): 104 | return _gammaCurve(0.5, x) 105 | def gamma1(x): 106 | return _gammaCurve(1, x) 107 | def gamma2(x): 108 | return _gammaCurve(2, x) 109 | 110 | plotfunc(0.0, 255.0, 1.0, development1) 111 | plotfunc(0.0, 255.0, 1.0, gamma05) 112 | plotfunc(0.0, 255.0, 1.0, gamma1) 113 | plotfunc(0.0, 255.0, 1.0, gamma2) 114 | plt.show() 115 | -------------------------------------------------------------------------------- /filmgrainer/filmgrainer.py: -------------------------------------------------------------------------------- 1 | # Filmgrainer - by Lars Ole Pontoppidan - MIT License 2 | 3 | from PIL import Image, ImageFilter 4 | import os 5 | 6 | import filmgrainer.graingamma as graingamma 7 | import filmgrainer.graingen as graingen 8 | 9 | 10 | def _grainTypes(typ): 11 | # After rescaling to make different grain sizes, the standard deviation 12 | # of the pixel values change. The following values of grain size and power 13 | # have been imperically chosen to end up with approx the same standard 14 | # deviation in the result: 15 | if typ == 1: 16 | return (0.8, 63) # more interesting fine grain 17 | elif typ == 2: 18 | return (1, 45) # basic fine grain 19 | elif typ == 3: 20 | return (1.5, 50) # coarse grain 21 | elif typ == 4: 22 | return (1.6666, 50) # coarser grain 23 | else: 24 | raise ValueError("Unknown grain type: " + str(typ)) 25 | 26 | # Grain mask cache 27 | MASK_CACHE_PATH = "/tmp/mask-cache/" 28 | 29 | def _getGrainMask(img_width:int, img_height:int, saturation:float, grayscale:bool, grain_size:float, grain_gauss:float, seed): 30 | if grayscale: 31 | str_sat = "BW" 32 | sat = -1.0 # Graingen makes a grayscale image if sat is negative 33 | else: 34 | str_sat = str(saturation) 35 | sat = saturation 36 | 37 | filename = MASK_CACHE_PATH + "grain-%d-%d-%s-%s-%s-%d.png" % ( 38 | img_width, img_height, str_sat, str(grain_size), str(grain_gauss), seed) 39 | if os.path.isfile(filename): 40 | print("Reusing: %s" % filename) 41 | mask = Image.open(filename) 42 | else: 43 | mask = graingen.grainGen(img_width, img_height, grain_size, grain_gauss, sat, seed) 44 | print("Saving: %s" % filename) 45 | if not os.path.isdir(MASK_CACHE_PATH): 46 | os.mkdir(MASK_CACHE_PATH) 47 | mask.save(filename, format="png", compress_level=1) 48 | return mask 49 | 50 | 51 | def process(file_in:str, scale:float, src_gamma:float, grain_power:float, shadows:float, 52 | highs:float, grain_type:int, grain_sat:float, gray_scale:bool, sharpen:int, seed:int, file_out=None): 53 | 54 | print("Loading: " + file_in) 55 | img = Image.open(file_in).convert("RGB") 56 | org_width = img.size[0] 57 | org_height = img.size[1] 58 | 59 | if scale != 1.0: 60 | print("Scaling source image ...") 61 | img = img.resize((int(org_width / scale), int(org_height / scale)), 62 | resample = Image.LANCZOS) 63 | 64 | img_width = img.size[0] 65 | img_height = img.size[1] 66 | print("Size: %d x %d" % (img_width, img_height)) 67 | 68 | print("Calculating map ...") 69 | map = graingamma.Map.calculate(src_gamma, grain_power, shadows, highs) 70 | # map.saveToFile("map.png") 71 | 72 | print("Calculating grain stock ...") 73 | (grain_size, grain_gauss) = _grainTypes(grain_type) 74 | mask = _getGrainMask(img_width, img_height, grain_sat, gray_scale, grain_size, grain_gauss, seed) 75 | 76 | mask_pixels = mask.load() 77 | img_pixels = img.load() 78 | 79 | # Instead of calling map.lookup(a, b) for each pixel, use the map directly: 80 | lookup = map.map 81 | 82 | if gray_scale: 83 | print("Film graining image ... (grayscale)") 84 | for y in range(0, img_height): 85 | for x in range(0, img_width): 86 | m = mask_pixels[x, y] 87 | (r, g, b) = img_pixels[x, y] 88 | gray = int(0.21*r + 0.72*g + 0.07*b) 89 | #gray_lookup = map.lookup(gray, m) 90 | gray_lookup = lookup[gray, m] 91 | img_pixels[x, y] = (gray_lookup, gray_lookup, gray_lookup) 92 | else: 93 | print("Film graining image ...") 94 | for y in range(0, img_height): 95 | for x in range(0, img_width): 96 | (mr, mg, mb) = mask_pixels[x, y] 97 | (r, g, b) = img_pixels[x, y] 98 | r = lookup[r, mr] 99 | g = lookup[g, mg] 100 | b = lookup[b, mb] 101 | img_pixels[x, y] = (r, g, b) 102 | 103 | if scale != 1.0: 104 | print("Scaling image back to original size ...") 105 | img = img.resize((org_width, org_height), resample = Image.LANCZOS) 106 | 107 | if sharpen > 0: 108 | print("Sharpening image: %d pass ..." % sharpen) 109 | for x in range(sharpen): 110 | img = img.filter(ImageFilter.SHARPEN) 111 | 112 | print("Saving: " + file_out) 113 | img.save(file_out, quality=97) 114 | -------------------------------------------------------------------------------- /filmgrainer/main.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | import filmgrainer.filmgrainer as filmgrainer 4 | 5 | from filmgrainer import __version__ 6 | github = "https://github.com/larspontoppidan/filmgrainer" 7 | 8 | class Arguments: 9 | def __init__(self): 10 | self.gray_scale = False 11 | self.grain_type = 1 12 | self.grain_sat = 0.5 13 | self.grain_power = 0.7 14 | self.shadows = 0.2 15 | self.highs = 0.2 16 | self.scale = 1.0 17 | self.sharpen = 0 18 | self.src_gamma = 1.0 19 | self.seed = 1 20 | self.file_in = None 21 | self.file_out = None 22 | 23 | @staticmethod 24 | def parse(args): 25 | a = Arguments() 26 | while len(args) > 0: 27 | if len(args) == 1 and not args[0].startswith('-'): 28 | a.file_in = args[0] 29 | elif args[0] == "--gray": 30 | a.gray_scale = True 31 | elif args[0] == "--gamma": 32 | args.pop(0) 33 | a.src_gamma = float(args[0]) 34 | elif args[0] == "--type": 35 | args.pop(0) 36 | a.grain_type = int(args[0]) 37 | elif args[0] == "--sat": 38 | args.pop(0) 39 | a.grain_sat = float(args[0]) 40 | elif args[0] == "-o": 41 | args.pop(0) 42 | a.file_out = args[0] 43 | elif args[0] == "--seed": 44 | args.pop(0) 45 | a.seed = int(args[0]) 46 | elif args[0] == "--scale": 47 | args.pop(0) 48 | a.scale = float(args[0]) 49 | elif args[0] == "--sharpen": 50 | args.pop(0) 51 | a.sharpen = int(args[0]) 52 | elif args[0] == "--power": 53 | args.pop(0) 54 | sp = args[0].split(",") 55 | a.grain_power = float(sp[0]) 56 | a.highs = float(sp[1]) 57 | a.shadows = float(sp[2]) 58 | elif args[0] == "--version": 59 | version() 60 | sys.exit() 61 | elif args[0] == "-h": 62 | usage() 63 | sys.exit() 64 | else: 65 | raise Exception("Unknown option: " + args[0]) 66 | args.pop(0) 67 | return a 68 | 69 | def version(): 70 | print("filmgrainer v%s, see more: %s" % (__version__, github)) 71 | 72 | def usage(): 73 | print("""Usage: 74 | filmgrainer [OPTION] [OPTION] ... INPUTFILE 75 | 76 | Options: 77 | -------- 78 | --gamma Gamma compensate input, default: 1.0 79 | --gray Grayscale mode 80 | --type Grain type: 81 | 1: fine, 2: fine simple, 3: coarse, 4: coarser 82 | --sat Grain color saturation, 0.0 to 1.0 83 | --power ,, Grain power: overall, highlights, shadows 84 | --scale Scaling, default 1.0. This will scale the image before 85 | applying grain and scale back to original size 86 | afterwards for an increase in grain size. 87 | --sharpen Sharpen output, passes, default: 0 88 | --seed Seed for grain random generator 89 | -o Set output filename 90 | -h Show this help 91 | --version Show version 92 | 93 | Examples: 94 | --------- 95 | Coarse black and white look: 96 | filmgrainer --gray --type 3 --power 0.8,0.2,0.1 -o bw_neg.png input.jpg 97 | 98 | Heavily grained color negative look: 99 | filmgrainer --type 4 --sat 0.8 --power 1,0.2,0.2 -o color_neg.png input.jpg 100 | 101 | Gentle dias-film like grain: 102 | filmgrainer --type 1 --sat 0.6 --power 0.75,0.1,0.1 -o dias.png input.jpg 103 | 104 | Totally trashing a picture with grain: 105 | filmgrainer --type 1 --gray --power 1,0.3,0.2 --scale 3 --sharpen 1 -o trashed_bw.png input.jpg 106 | filmgrainer --type 1 --sat 0.8 --power 1,0.3,0.2 --scale 3 --sharpen 1 -o trashed.png input.jpg 107 | """) 108 | 109 | def main(): 110 | try: 111 | if len(sys.argv) == 1: 112 | version() 113 | print("") 114 | usage() 115 | return 116 | args = Arguments.parse(sys.argv[1:]) 117 | if args.file_in is None: 118 | raise ValueError("No input file specified") 119 | except Exception as e: 120 | print("Error: %s" % str(e)) 121 | sys.exit(-1) 122 | else: 123 | if args.file_out is None: 124 | args.file_out = args.file_in + "-grain.png" 125 | 126 | filmgrainer.process(args.file_in, args.scale, args.src_gamma, 127 | args.grain_power, args.shadows, args.highs, args.grain_type, 128 | args.grain_sat, args.gray_scale, args.sharpen, args.seed, args.file_out) 129 | 130 | if __name__ == "__main__": 131 | main() 132 | --------------------------------------------------------------------------------