diff --git a/exposure_triangle.ipynb b/exposure_triangle.ipynb
new file mode 100644
index 0000000..c135e35
--- /dev/null
+++ b/exposure_triangle.ipynb
@@ -0,0 +1,290 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%matplotlib widget\n",
+ "\n",
+ "from PIL import Image, ImageFilter, ImageEnhance, ImageDraw, ImageFont\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "\n",
+ "class parameter:\n",
+ " \"\"\"\n",
+ " Base class for parameters.\n",
+ " The scale is a dict that links the steps (from the slider) to a real value.\n",
+ " \"\"\"\n",
+ " def __init__(self, scale, step = 0):\n",
+ " self.step = None\n",
+ " self.scale = scale\n",
+ " self.update(step)\n",
+ " \n",
+ " def update(self, step = None):\n",
+ " if step is not None:\n",
+ " if step in self.scale:\n",
+ " self.step = step\n",
+ " pass\n",
+ " \n",
+ " def getEvValue(self):\n",
+ " return self.step\n",
+ " \n",
+ " def getValue(self):\n",
+ " if self.step in self.scale:\n",
+ " return self.scale[self.step]\n",
+ " return None\n",
+ "\n",
+ "\n",
+ "class blurImage(parameter):\n",
+ " \"\"\"\n",
+ " A 'parameter' class that blurs a base image, based on the current step.\n",
+ " Adds methods to deal with the images.\n",
+ " \"\"\"\n",
+ " def __init__(self,\n",
+ " scale = {0: 1/500, 1: 1/250, 2: 1/125, 3: 1/60, 4: 1/30, 5: 1/15, 6: 1/8, 7: 1/3, 8: 1/2, 9: 1},\n",
+ " step = 0,\n",
+ " baseImage = None):\n",
+ " super().__init__(scale, step = step)\n",
+ " self.images = {}\n",
+ " if baseImage is not None: self.images[0] = Image.open(baseImage)\n",
+ " self._initImages()\n",
+ " \n",
+ " def getImage(self):\n",
+ " return self.images[self.step] \n",
+ "\n",
+ " def _initImages(self):\n",
+ " for step in range(1, 10):\n",
+ " self.images[step] = self.images[step-1].filter(ImageFilter.BLUR)\n",
+ " \n",
+ "class shutterSpeed(blurImage):\n",
+ " \"\"\"\n",
+ " 'shutterSpeed' class. It blurs the base image, based on the shutter speed.\n",
+ " \"\"\"\n",
+ " def __init__(self,\n",
+ " scale = {0: 1/500, 1: 1/250, 2: 1/125, 3: 1/60, 4: 1/30, 5: 1/15, 6: 1/8, 7: 1/3, 8: 1/2, 9: 1},\n",
+ " baseImage = 'images/flying.png'):\n",
+ " super().__init__(scale, 0, baseImage)\n",
+ "\n",
+ "class aperture(blurImage):\n",
+ " \"\"\"\n",
+ " 'aperture' class. It blurs the background image, based on the aperture.\n",
+ " \"\"\"\n",
+ " def __init__(self,\n",
+ " scale = {0: 22, 1: 16, 2: 11, 3: 8, 4: 5.6, 5: 4, 6: 2.8, 7: 2, 8: 1.4, 9: 1},\n",
+ " baseImage = 'images/background.jpg'):\n",
+ " super().__init__(scale, 0, baseImage)\n",
+ "\n",
+ "class ISO(parameter):\n",
+ " \"\"\"\n",
+ " 'ISO' class is a simple 'parameter' class with a specific scale.\n",
+ " It also provides a function to get the grain 'percentage' to apply to the final image\n",
+ " \"\"\"\n",
+ " def __init__(self, scale = {0: 50, 1: 100, 2: 200, 3: 400, 4: 800, 5: 1600, 6: 3200, 7: 6400, 8: 12800, 9: 25600}):\n",
+ " super().__init__(scale, step = 0)\n",
+ " \n",
+ " def getGrainPercentage(self):\n",
+ " return self.step*50./9\n",
+ " \n",
+ "class weather(parameter):\n",
+ " \"\"\"\n",
+ " 'weather' class. The scale has 3 values 0/1/2 corresponding to sunny/partially/cloudy\n",
+ " that affect the EV (0/-5/-10)\n",
+ " \"\"\"\n",
+ " def __init__(self, scale = {0: 0, 1: -5, 2: -10}):\n",
+ " super().__init__(scale, step = 0)\n",
+ " \n",
+ " def getEvValue(self):\n",
+ " return self.scale[self.step]\n",
+ " \n",
+ " def getPrettyName(self):\n",
+ " v = {0: 'sunny', 1: 'partially cloudy', 2: 'cloudy'}\n",
+ " return v[self.step]\n",
+ " \n",
+ "class exposureTriangle:\n",
+ " \"\"\"\n",
+ " Main class of the exposire triangle.\n",
+ " It instantiates the shutterSpeed, Aperture, ISO and weather objects\n",
+ " \"\"\"\n",
+ " def __init__(self):\n",
+ " self.spd = shutterSpeed()\n",
+ " self.ape = aperture()\n",
+ " self.iso = ISO()\n",
+ " self.wea = weather()\n",
+ " self.image = None\n",
+ " self.updateImage()\n",
+ " self.redo_image = True\n",
+ " \n",
+ " def calculateEv(self):\n",
+ " EV=self.spd.getEvValue()+self.ape.getEvValue()+self.iso.getEvValue()+self.wea.getEvValue()-4\n",
+ " return int(EV)\n",
+ " \n",
+ " def setWea(self, step):\n",
+ " self.wea.update(step)\n",
+ " self.redo_image = True\n",
+ " \n",
+ " def setSpd(self, step):\n",
+ " self.spd.update(step)\n",
+ " self.redo_image = True\n",
+ "\n",
+ " def setApe(self, step):\n",
+ " self.ape.update(step)\n",
+ " self.redo_image = True\n",
+ " \n",
+ " def setIso(self, step=1):\n",
+ " self.iso.update(step)\n",
+ " self.redo_image = True\n",
+ "\n",
+ " def updateImage(self):\n",
+ " # Place background (from Aperture)\n",
+ " self.image = self.ape.getImage()\n",
+ " # Place foreground (from ShutterSpeed)\n",
+ " self.image.paste(self.spd.getImage(), (0, 0), self.spd.getImage())\n",
+ " # Add ISO grain\n",
+ " self._addIsoGrain(self.iso.getGrainPercentage())\n",
+ " # Correct brightness (based on EV value)\n",
+ " ev = self.calculateEv()\n",
+ " ev_text = 'EV: '\n",
+ " if ev <= 0:\n",
+ " ev_text += str(ev)\n",
+ " if ev < 0:\n",
+ " #ev = ev -1\n",
+ " #ev=-1./ev\n",
+ " pass\n",
+ " else:\n",
+ " ev_text += '+'+str(ev)\n",
+ " #ev += 1\n",
+ " if ev != 0:\n",
+ " bright = ImageEnhance.Brightness(self.image)\n",
+ " self.image = bright.enhance(2**(ev/3))\n",
+ " # Add EV (overlay)\n",
+ " font = ImageFont.truetype(r'DejaVuSans-Bold.ttf', 16)\n",
+ " ImageDraw.Draw(self.image).text((10, 10), ev_text, (255, 0, 0), font = font)\n",
+ " self.redo_image = False\n",
+ " \n",
+ " def getImage(self):\n",
+ " if self.redo_image: self.updateImage()\n",
+ " return self.image\n",
+ "\n",
+ " def prettyString(self):\n",
+ " ps = \"Current settings:
\"\n", + " ps += \"Weather : {}\".format(self.calculateEv())\n", + " return ps\n", + " \n", + " def prettyPrint(self):\n", + " print(self.prettyString())\n", + " \n", + " def _addIsoGrain(self, amount, shift = 1.1):\n", + " output = np.copy(np.array(self.image))\n", + " x_size, y_size, z_size = output.shape\n", + " pixels = (np.random.rand(x_size, y_size)*100/(100-amount)).astype(int)\n", + " it = np.nditer(pixels, flags=['multi_index'])\n", + " while not it.finished:\n", + " if it[0]:\n", + " output[it.multi_index] = [min(output[it.multi_index][0]*shift, 255),\n", + " min(output[it.multi_index][1]*shift, 255),\n", + " min(output[it.multi_index][2]*shift, 255)]\n", + " it.iternext()\n", + " self.image = Image.fromarray(output)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Let's build a minimalist UI\n", + "\n", + "from IPython.display import display, clear_output, display_pretty, display_jpeg, display_html\n", + "from ipywidgets import widgets, HBox, VBox\n", + "\n", + "# First, we need a triangle :-)\n", + "tri = exposureTriangle()\n", + "\n", + "# We need an output (to display the resulting image)\n", + "output = widgets.Output()\n", + "# We need an HTML widget to displayt the current settings\n", + "html = widgets.HTML()\n", + "\n", + "# This function will be notified of any change in the settings\n", + "# and will calculate a new image and display it\n", + "def on_change(change):\n", + " with output:\n", + " new = change['new']\n", + " if change['owner'].description == 'Shut. Speed':\n", + " tri.setSpd(new)\n", + " elif change['owner'].description == 'Aperture':\n", + " tri.setApe(new)\n", + " elif change['owner'].description == 'ISO':\n", + " tri.setIso(new)\n", + " elif change['owner'].description == 'Weather':\n", + " tri.setWea(new)\n", + " else:\n", + " pass\n", + " new_img = tri.getImage()\n", + " clear_output(wait=True)\n", + " display(new_img)\n", + " html.value = tri.prettyString()\n", + "\n", + "# We need sliders to control the triangle parameters\n", + "s1 = widgets.IntSlider(value=0, min=0, max=2, step=1, description='Weather', continuous_update=False, readout=False)\n", + "s2 = widgets.IntSlider(value=0, min=0, max=9, step=1, description='Shut. Speed', continuous_update=False, readout=False)\n", + "s3 = widgets.IntSlider(value=0, min=0, max=9, step=1, description='Aperture', continuous_update=False, readout=False)\n", + "s4 = widgets.IntSlider(value=0, min=0, max=9, step=1, description='ISO', continuous_update=False, readout=False)\n", + "\n", + "# Now we link the sliders to the 'on_change' function\n", + "s1.observe(on_change, 'value')\n", + "s2.observe(on_change, 'value')\n", + "s3.observe(on_change, 'value')\n", + "s4.observe(on_change, 'value')\n", + "\n", + "# Let's organise all the widgets in a single UI\n", + "sliderBox = VBox()\n", + "sliderBox.children = [s1, s2, s3, s4, html]\n", + "ui = HBox()\n", + "ui.children = [sliderBox, output]\n", + "\n", + "# Let's initialise the display (HTML and image)\n", + "html.value = tri.prettyString() \n", + "with output: display(tri.getImage())\n", + "\n", + "# Show the UI\n", + "display(ui)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/images/background.jpg b/images/background.jpg new file mode 100644 index 0000000..15cabe1 Binary files /dev/null and b/images/background.jpg differ diff --git a/images/flying.png b/images/flying.png new file mode 100644 index 0000000..afc2135 Binary files /dev/null and b/images/flying.png differ
\".format(self.wea.getPrettyName())\n", + " ps += \"Shutter speed (s): 1/{}
\".format(int(1/self.spd.getValue()))\n", + " ps += \"Aperture : f/{}
\".format(self.ape.getValue())\n", + " ps += \"ISO : {}
\".format(self.iso.getValue())\n", + " ps += \"EV : {}