├── runtime.txt ├── Procfile ├── notebooks ├── run.bat ├── Exploring Histograms with PIL.ipynb ├── Exploring PIL Processing.ipynb └── Image Components Functions Tests.ipynb ├── images ├── default.jpg ├── animated1.gif ├── screenshot1.png └── screenshot2.png ├── .gitignore ├── requirements.txt ├── LICENSE.md ├── custom_styles.css ├── assets ├── custom_styles.css ├── normalize.min.css ├── fonts.css ├── dash_template.css └── font-awesome.min.css ├── CONTRIBUTING.md ├── utils.py ├── README.md ├── dash_reusable_components.py └── app.py /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.6.6 -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn app:server -------------------------------------------------------------------------------- /notebooks/run.bat: -------------------------------------------------------------------------------- 1 | jupyter notebook -------------------------------------------------------------------------------- /images/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/dash-image-processing/HEAD/images/default.jpg -------------------------------------------------------------------------------- /images/animated1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/dash-image-processing/HEAD/images/animated1.gif -------------------------------------------------------------------------------- /images/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/dash-image-processing/HEAD/images/screenshot1.png -------------------------------------------------------------------------------- /images/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotly/dash-image-processing/HEAD/images/screenshot2.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | *.xml 3 | *.pyc 4 | images/IU.jpg 5 | images/cats.jpg 6 | notebooks/.ipynb_checkpoints 7 | credentials.py 8 | .env 9 | cache-directory -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dash==0.36.0 2 | dash-html-components==0.13.5 3 | dash-core-components==0.43.0 4 | dash-renderer==0.17.0 5 | numpy==1.16.3 6 | pandas==0.24.2 7 | plotly==2.7.0 8 | flask==1.0.1 9 | gunicorn==19.8.1 10 | scipy==1.2.1 11 | pillow==6.0.0 12 | Flask-Caching==1.4.0 13 | redis==2.10.6 14 | boto3==1.7.48 15 | requests==2.18.4 16 | python-dotenv==0.8.2 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 plotly 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. -------------------------------------------------------------------------------- /custom_styles.css: -------------------------------------------------------------------------------- 1 | ._dash-loading-callback { 2 | font-family: sans-serif; 3 | padding-top: 50px; 4 | color: rgb(90, 90, 90); 5 | 6 | -webkit-animation: fadein 0.5s ease-in 7s forwards; /* Safari, Chrome and Opera > 12.1 */ 7 | -moz-animation: fadein 0.5s; /* Firefox < 16 */ 8 | -ms-animation: fadein 0.5s; /* Internet Explorer */ 9 | -o-animation: fadein 0.5s; /* Opera < 12.1 */ 10 | animation: fadein 0.5s ease-in 7s forwards; 11 | 12 | 13 | 14 | /* The banner */ 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | width: 100%; 19 | height: 100%; 20 | background-color: rgba(255, 255, 255, 0.5); 21 | text-align: center; 22 | cursor: progress; 23 | } 24 | 25 | 26 | @keyframes fadein { 27 | from { opacity: 0; } 28 | to { opacity: 1; } 29 | } 30 | 31 | /* Firefox < 16 */ 32 | @-moz-keyframes fadein { 33 | from { opacity: 0; } 34 | to { opacity: 1; } 35 | } 36 | 37 | /* Safari, Chrome and Opera > 12.1 */ 38 | @-webkit-keyframes fadein { 39 | from { opacity: 0; } 40 | to { opacity: 1; } 41 | } 42 | 43 | /* Internet Explorer */ 44 | @-ms-keyframes fadein { 45 | from { opacity: 0; } 46 | to { opacity: 1; } 47 | } 48 | 49 | /* Opera < 12.1 */ 50 | @-o-keyframes fadein { 51 | from { opacity: 0; } 52 | to { opacity: 1; } 53 | } 54 | 55 | /* Removes Undo Button 56 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 57 | ._dash-undo-redo { 58 | display: none; 59 | } -------------------------------------------------------------------------------- /assets/custom_styles.css: -------------------------------------------------------------------------------- 1 | ._dash-loading-callback { 2 | font-family: sans-serif; 3 | padding-top: 50px; 4 | color: rgb(90, 90, 90); 5 | 6 | -webkit-animation: fadein 0.5s ease-in 7s forwards; /* Safari, Chrome and Opera > 12.1 */ 7 | -moz-animation: fadein 0.5s; /* Firefox < 16 */ 8 | -ms-animation: fadein 0.5s; /* Internet Explorer */ 9 | -o-animation: fadein 0.5s; /* Opera < 12.1 */ 10 | animation: fadein 0.5s ease-in 7s forwards; 11 | 12 | 13 | 14 | /* The banner */ 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | width: 100%; 19 | height: 100%; 20 | background-color: rgba(255, 255, 255, 0.5); 21 | text-align: center; 22 | cursor: progress; 23 | } 24 | 25 | 26 | @keyframes fadein { 27 | from { opacity: 0; } 28 | to { opacity: 1; } 29 | } 30 | 31 | /* Firefox < 16 */ 32 | @-moz-keyframes fadein { 33 | from { opacity: 0; } 34 | to { opacity: 1; } 35 | } 36 | 37 | /* Safari, Chrome and Opera > 12.1 */ 38 | @-webkit-keyframes fadein { 39 | from { opacity: 0; } 40 | to { opacity: 1; } 41 | } 42 | 43 | /* Internet Explorer */ 44 | @-ms-keyframes fadein { 45 | from { opacity: 0; } 46 | to { opacity: 1; } 47 | } 48 | 49 | /* Opera < 12.1 */ 50 | @-o-keyframes fadein { 51 | from { opacity: 0; } 52 | to { opacity: 1; } 53 | } 54 | 55 | /* Removes Undo Button 56 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 57 | ._dash-undo-redo { 58 | display: none; 59 | } -------------------------------------------------------------------------------- /assets/normalize.min.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}/*# sourceMappingURL=normalize.min.css.map */ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import dash_core_components as dcc 2 | import dash_html_components as html 3 | import json 4 | import plotly.graph_objs as go 5 | import dash_reusable_components as drc 6 | from PIL import Image, ImageFilter, ImageDraw, ImageEnhance 7 | 8 | 9 | # [filename, image_signature, action_stack] 10 | STORAGE_PLACEHOLDER = json.dumps({ 11 | 'filename': None, 12 | 'image_signature': None, 13 | 'action_stack': [] 14 | }) 15 | 16 | IMAGE_STRING_PLACEHOLDER = drc.pil_to_b64(Image.open('images/default.jpg').copy(), enc_format='jpeg') 17 | 18 | GRAPH_PLACEHOLDER = dcc.Graph(id='interactive-image', style={'height': '80vh'}) 19 | 20 | # Maps process name to the Image filter corresponding to that process 21 | FILTERS_DICT = { 22 | 'blur': ImageFilter.BLUR, 23 | 'contour': ImageFilter.CONTOUR, 24 | 'detail': ImageFilter.DETAIL, 25 | 'edge_enhance': ImageFilter.EDGE_ENHANCE, 26 | 'edge_enhance_more': ImageFilter.EDGE_ENHANCE_MORE, 27 | 'emboss': ImageFilter.EMBOSS, 28 | 'find_edges': ImageFilter.FIND_EDGES, 29 | 'sharpen': ImageFilter.SHARPEN, 30 | 'smooth': ImageFilter.SMOOTH, 31 | 'smooth_more': ImageFilter.SMOOTH_MORE 32 | } 33 | 34 | ENHANCEMENT_DICT = { 35 | 'color': ImageEnhance.Color, 36 | 'contrast': ImageEnhance.Contrast, 37 | 'brightness': ImageEnhance.Brightness, 38 | 'sharpness': ImageEnhance.Sharpness 39 | } 40 | 41 | 42 | def generate_lasso_mask(image, selectedData): 43 | """ 44 | Generates a polygon mask using the given lasso coordinates 45 | :param selectedData: The raw coordinates selected from the data 46 | :return: The polygon mask generated from the given coordinate 47 | """ 48 | 49 | height = image.size[1] 50 | y_coords = selectedData['lassoPoints']['y'] 51 | y_coords_corrected = [height - coord for coord in y_coords] 52 | 53 | coordinates_tuple = list(zip(selectedData['lassoPoints']['x'], y_coords_corrected)) 54 | mask = Image.new('L', image.size) 55 | draw = ImageDraw.Draw(mask) 56 | draw.polygon(coordinates_tuple, fill=255) 57 | 58 | return mask 59 | 60 | 61 | def apply_filters(image, zone, filter, mode): 62 | filter_selected = FILTERS_DICT[filter] 63 | 64 | if mode == 'select': 65 | crop = image.crop(zone) 66 | crop_mod = crop.filter(filter_selected) 67 | image.paste(crop_mod, zone) 68 | 69 | elif mode == 'lasso': 70 | im_filtered = image.filter(filter_selected) 71 | image.paste(im_filtered, mask=zone) 72 | 73 | 74 | def apply_enhancements(image, zone, enhancement, enhancement_factor, mode): 75 | enhancement_selected = ENHANCEMENT_DICT[enhancement] 76 | enhancer = enhancement_selected(image) 77 | 78 | im_enhanced = enhancer.enhance(enhancement_factor) 79 | 80 | if mode == 'select': 81 | crop = im_enhanced.crop(zone) 82 | image.paste(crop, box=zone) 83 | 84 | elif mode == 'lasso': 85 | image.paste(im_enhanced, mask=zone) 86 | 87 | 88 | def show_histogram(image): 89 | def hg_trace(name, color, hg): 90 | line = go.Scatter( 91 | x=list(range(0, 256)), 92 | y=hg, 93 | name=name, 94 | line=dict(color=(color)), 95 | mode='lines', 96 | showlegend=False 97 | ) 98 | fill = go.Scatter( 99 | x=list(range(0, 256)), 100 | y=hg, 101 | mode='fill', 102 | name=name, 103 | line=dict(color=(color)), 104 | fill='tozeroy', 105 | hoverinfo='none' 106 | ) 107 | 108 | return line, fill 109 | 110 | hg = image.histogram() 111 | 112 | if image.mode == 'RGBA': 113 | rhg = hg[0:256] 114 | ghg = hg[256:512] 115 | bhg = hg[512:768] 116 | ahg = hg[768:] 117 | 118 | data = [ 119 | *hg_trace('Red', '#FF4136', rhg), 120 | *hg_trace('Green', '#2ECC40', ghg), 121 | *hg_trace('Blue', '#0074D9', bhg), 122 | *hg_trace('Alpha', 'gray', ahg) 123 | ] 124 | 125 | title = 'RGBA Histogram' 126 | 127 | elif image.mode == 'RGB': 128 | # Returns a 768 member array with counts of R, G, B values 129 | rhg = hg[0:256] 130 | ghg = hg[256:512] 131 | bhg = hg[512:768] 132 | 133 | data = [ 134 | *hg_trace('Red', '#FF4136', rhg), 135 | *hg_trace('Green', '#2ECC40', ghg), 136 | *hg_trace('Blue', '#0074D9', bhg), 137 | ] 138 | 139 | title = 'RGB Histogram' 140 | 141 | else: 142 | data = [*hg_trace('Gray', 'gray', hg)] 143 | 144 | title = 'Grayscale Histogram' 145 | 146 | layout = go.Layout( 147 | title=title, 148 | margin=go.Margin(l=35, r=35), 149 | legend=dict(x=0, y=1.15, orientation="h") 150 | ) 151 | 152 | return go.Figure(data=data, layout=layout) 153 | -------------------------------------------------------------------------------- /assets/fonts.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Open Sans'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/mem8YaGs126MiZpBA-UFWJ0bbck.woff2) format('woff2'); 7 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 8 | } 9 | /* cyrillic */ 10 | @font-face { 11 | font-family: 'Open Sans'; 12 | font-style: normal; 13 | font-weight: 400; 14 | src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/mem8YaGs126MiZpBA-UFUZ0bbck.woff2) format('woff2'); 15 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 16 | } 17 | /* greek-ext */ 18 | @font-face { 19 | font-family: 'Open Sans'; 20 | font-style: normal; 21 | font-weight: 400; 22 | src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/mem8YaGs126MiZpBA-UFWZ0bbck.woff2) format('woff2'); 23 | unicode-range: U+1F00-1FFF; 24 | } 25 | /* greek */ 26 | @font-face { 27 | font-family: 'Open Sans'; 28 | font-style: normal; 29 | font-weight: 400; 30 | src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/mem8YaGs126MiZpBA-UFVp0bbck.woff2) format('woff2'); 31 | unicode-range: U+0370-03FF; 32 | } 33 | /* vietnamese */ 34 | @font-face { 35 | font-family: 'Open Sans'; 36 | font-style: normal; 37 | font-weight: 400; 38 | src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/mem8YaGs126MiZpBA-UFWp0bbck.woff2) format('woff2'); 39 | unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB; 40 | } 41 | /* latin-ext */ 42 | @font-face { 43 | font-family: 'Open Sans'; 44 | font-style: normal; 45 | font-weight: 400; 46 | src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/mem8YaGs126MiZpBA-UFW50bbck.woff2) format('woff2'); 47 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Open Sans'; 52 | font-style: normal; 53 | font-weight: 400; 54 | src: local('Open Sans Regular'), local('OpenSans-Regular'), url(https://fonts.gstatic.com/s/opensans/v15/mem8YaGs126MiZpBA-UFVZ0b.woff2) format('woff2'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 56 | } 57 | /* cyrillic-ext */ 58 | @font-face { 59 | font-family: 'Roboto'; 60 | font-style: normal; 61 | font-weight: 400; 62 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu72xKOzY.woff2) format('woff2'); 63 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 64 | } 65 | /* cyrillic */ 66 | @font-face { 67 | font-family: 'Roboto'; 68 | font-style: normal; 69 | font-weight: 400; 70 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu5mxKOzY.woff2) format('woff2'); 71 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 72 | } 73 | /* greek-ext */ 74 | @font-face { 75 | font-family: 'Roboto'; 76 | font-style: normal; 77 | font-weight: 400; 78 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu7mxKOzY.woff2) format('woff2'); 79 | unicode-range: U+1F00-1FFF; 80 | } 81 | /* greek */ 82 | @font-face { 83 | font-family: 'Roboto'; 84 | font-style: normal; 85 | font-weight: 400; 86 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu4WxKOzY.woff2) format('woff2'); 87 | unicode-range: U+0370-03FF; 88 | } 89 | /* vietnamese */ 90 | @font-face { 91 | font-family: 'Roboto'; 92 | font-style: normal; 93 | font-weight: 400; 94 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu7WxKOzY.woff2) format('woff2'); 95 | unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB; 96 | } 97 | /* latin-ext */ 98 | @font-face { 99 | font-family: 'Roboto'; 100 | font-style: normal; 101 | font-weight: 400; 102 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu7GxKOzY.woff2) format('woff2'); 103 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 104 | } 105 | /* latin */ 106 | @font-face { 107 | font-family: 'Roboto'; 108 | font-style: normal; 109 | font-weight: 400; 110 | src: local('Roboto'), local('Roboto-Regular'), url(https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu4mxK.woff2) format('woff2'); 111 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 112 | } 113 | -------------------------------------------------------------------------------- /notebooks/Exploring Histograms with PIL.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 34, 6 | "metadata": {}, 7 | "outputs": [ 8 | { 9 | "data": { 10 | "text/html": [ 11 | "" 12 | ] 13 | }, 14 | "metadata": {}, 15 | "output_type": "execute_result" 16 | } 17 | ], 18 | "source": [ 19 | "from PIL import Image\n", 20 | "import plotly.offline as py\n", 21 | "import plotly.graph_objs as go\n", 22 | "import pandas as pd\n", 23 | "import numpy as np\n", 24 | "\n", 25 | "py.init_notebook_mode(connected=True)" 26 | ] 27 | }, 28 | { 29 | "cell_type": "code", 30 | "execution_count": 106, 31 | "metadata": {}, 32 | "outputs": [], 33 | "source": [ 34 | "def hg_trace(name, color, hg):\n", 35 | " line = go.Scatter(\n", 36 | " x=list(range(0,256)), \n", 37 | " y=hg, \n", 38 | " name=name,\n", 39 | " line=dict(color=(color)),\n", 40 | " mode='lines',\n", 41 | " showlegend=False\n", 42 | " )\n", 43 | " \n", 44 | " fill = go.Scatter(\n", 45 | " x=list(range(0,256)),\n", 46 | " y=hg,\n", 47 | " mode='fill',\n", 48 | " name=name,\n", 49 | " line=dict(color=(color)),\n", 50 | " fill='tozeroy',\n", 51 | " hoverinfo='none'\n", 52 | " )\n", 53 | " \n", 54 | " return line, fill" 55 | ] 56 | }, 57 | { 58 | "cell_type": "markdown", 59 | "metadata": {}, 60 | "source": [ 61 | "### Testing Grayscale" 62 | ] 63 | }, 64 | { 65 | "cell_type": "code", 66 | "execution_count": null, 67 | "metadata": {}, 68 | "outputs": [], 69 | "source": [ 70 | "im = Image.open('../images/cats.jpg').convert('L')\n", 71 | "\n", 72 | "hg = im.histogram()\n", 73 | "\n", 74 | "data = [*hg_trace('Gray','gray', rhg)]\n", 75 | "\n", 76 | "\n", 77 | "layout = go.Layout(\n", 78 | " title='Grayscale Histogram',\n", 79 | ")\n", 80 | "\n", 81 | "figure = go.Figure(data=data, layout=layout)\n", 82 | "\n", 83 | "py.iplot(figure,\n", 84 | " filename='plot')" 85 | ] 86 | }, 87 | { 88 | "cell_type": "markdown", 89 | "metadata": {}, 90 | "source": [ 91 | "### Testing RGB" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": null, 97 | "metadata": {}, 98 | "outputs": [], 99 | "source": [ 100 | "im = Image.open('../images/cats.jpg')\n", 101 | "\n", 102 | "hg = im.histogram()\n", 103 | "# Returns a 768 member array with counts of R, G, B values\n", 104 | "rhg = hg[0:256]\n", 105 | "ghg = hg[256:512]\n", 106 | "bhg = hg[512:768]\n", 107 | "\n", 108 | "data = [*hg_trace('Red','red', rhg), \n", 109 | " *hg_trace('Green', 'green', ghg),\n", 110 | " *hg_trace('Blue','blue', bhg)]\n", 111 | "\n", 112 | "layout = go.Layout(\n", 113 | " title='RGB Histogram',\n", 114 | ")\n", 115 | "\n", 116 | "figure = go.Figure(data=data, layout=layout)\n", 117 | "\n", 118 | "py.iplot(figure,\n", 119 | " filename='plot')" 120 | ] 121 | }, 122 | { 123 | "cell_type": "markdown", 124 | "metadata": {}, 125 | "source": [ 126 | "### Testing RGBA" 127 | ] 128 | }, 129 | { 130 | "cell_type": "code", 131 | "execution_count": null, 132 | "metadata": { 133 | "scrolled": false 134 | }, 135 | "outputs": [], 136 | "source": [ 137 | "im_rgba = Image.open(\"../images/iu_rgba.png\")\n", 138 | "im_rgba.mode\n", 139 | "\n", 140 | "hg = im_rgba.histogram()\n", 141 | "# Returns a 768 member array with counts of R, G, B values\n", 142 | "rhg = hg[0:256]\n", 143 | "ghg = hg[256:512]\n", 144 | "bhg = hg[512:768]\n", 145 | "ahg = hg[768:]\n", 146 | "\n", 147 | "data = [\n", 148 | " *hg_trace('Red','red', rhg), \n", 149 | " *hg_trace('Green', 'green', ghg),\n", 150 | " *hg_trace('Blue','blue', bhg),\n", 151 | " *hg_trace('Alpha', 'gray', ahg)\n", 152 | "]\n", 153 | "\n", 154 | "layout = go.Layout(\n", 155 | " title='RGBA Histogram',\n", 156 | ")\n", 157 | "\n", 158 | "figure = go.Figure(data=data, layout=layout)\n", 159 | "\n", 160 | "py.iplot(figure,\n", 161 | " filename='plot')" 162 | ] 163 | } 164 | ], 165 | "metadata": { 166 | "kernelspec": { 167 | "display_name": "Python 3", 168 | "language": "python", 169 | "name": "python3" 170 | }, 171 | "language_info": { 172 | "codemirror_mode": { 173 | "name": "ipython", 174 | "version": 3 175 | }, 176 | "file_extension": ".py", 177 | "mimetype": "text/x-python", 178 | "name": "python", 179 | "nbconvert_exporter": "python", 180 | "pygments_lexer": "ipython3", 181 | "version": "3.6.3" 182 | } 183 | }, 184 | "nbformat": 4, 185 | "nbformat_minor": 2 186 | } 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dash Image Processing App 2 | 3 | 4 | [![GitHub license](https://img.shields.io/github/license/plotly/dash-image-processing.svg)](https://github.com/plotly/dash-image-processing/blob/master/LICENSE.md) 5 | [![GitHub issues](https://img.shields.io/github/issues/plotly/dash-image-processing.svg)](https://github.com/plotly/dash-image-processing/issues) 6 | [![GitHub forks](https://img.shields.io/github/forks/plotly/dash-image-processing.svg)](https://github.com/plotly/dash-image-processing/network) 7 | [![GitHub stars](https://img.shields.io/github/stars/plotly/dash-image-processing.svg)](https://github.com/plotly/dash-image-processing/stargazers) 8 | 9 | This is a demo of the Dash interactive Python framework developed by [Plotly](https://plot.ly/). 10 | 11 | Dash abstracts away all of the technologies and protocols required to build an interactive web-based application and is a simple and effective way to bind a user interface around your Python code. To learn more check out our [documentation](https://plot.ly/dash). 12 | 13 | Try out the [demo app here](https://dash-image-processing.plot.ly/). 14 | 15 | ![animated1](images/animated1.gif) 16 | 17 | 18 | ## Getting Started 19 | ### Using the demo 20 | This demo lets you interactively explore different image processing 21 | filters. You can upload your own image or use the one provided with the 22 | demo. 23 | 24 | Filters can be applied either to the whole image, or to selections 25 | created with one of the selection tools. 26 | 27 | ### Running the app locally 28 | 29 | First create a virtual environment with conda or venv inside a temp folder, then activate it. 30 | 31 | ``` 32 | virtualenv dash-image-processing-venv 33 | 34 | # Windows 35 | dash-image-processing-venv\Scripts\activate 36 | # Or Linux 37 | source venv/bin/activate 38 | ``` 39 | 40 | Clone the git repo, then install the requirements with pip 41 | ``` 42 | git clone https://github.com/plotly/dash-image-processing.git 43 | cd dash-image-processing 44 | pip install -r requirements.txt 45 | ``` 46 | 47 | Run the app 48 | ``` 49 | python app.py 50 | ``` 51 | 52 | 53 | ## Development 54 | 55 | ### S3 Storage 56 | 57 | This app uses S3 to store user input images. To use this app locally, make sure to create a `.env` file in the root directory with the following content: 58 | ``` 59 | BUCKETEER_AWS_SECRET_ACCESS_KEY=*********** 60 | BUCKETEER_AWS_ACCESS_KEY_ID=*********** 61 | BUCKETEER_BUCKET_NAME=********** 62 | ``` 63 | 64 | All this information is given when you create a bucket in AWS. For Plotly devs, the bucket name is `dash-image-processing-bucket`. 65 | 66 | ### Dash Deployment Server 67 | If you are looking to host this app on the Dash Deployment Server, make sure: 68 | * That you have linked a Redis database to your app. 69 | * To configure S3 storage by adding the content of `.env` as environment variables (in Settings). 70 | 71 | ## About the app 72 | This app wraps Pillow, a powerful image processing library in Python, and abstracts all the operations through an easy-to-use GUI. All the computation is done on the back-end through Dash, and image transfer is optimized through session-based Redis caching and S3 storage. 73 | 74 | ### Motivation 75 | Recently, while we were experimenting with ImageJ, an image processing app in Java, we wondered if it was possible to bring two changes: port the app into a browser interface, and shift the computation to the backend (so that extremely large images can also be processed). 76 | 77 | This is how we thought about making a Dash app that would wrap Pillow, the modern version of the Python Imaging Library. This was the natural thing to do because Dash itself is already based on Flask, and Plotly already has the graph objects for manipulating images. Adding S3 storage to keep the uploaded file and caching the operations with Redis were absolutely painless because of the easy integration with Python. 78 | 79 | ## Built With 80 | * [Dash](https://dash.plot.ly/) - Main server and interactive components 81 | * [Plotly Python](https://plot.ly/python/) - Used to create the interactive plots 82 | * [Pillow](http://scikit-learn.org/stable/documentation.html) - Apply operations to images 83 | * [Boto S3](http://boto.cloudhackers.com/en/latest/ref/s3.html) - Store User inputted images 84 | * [Redis](https://redis.io/documentation) - Cache the user input 85 | 86 | ## Contributing 87 | 88 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 89 | Although only a subset of Pillow is currently present, you are welcome to add any type of plugins, e.g. ML-based image processing. Just visit the project repo and make a PR with your addition: https://github.com/plotly/dash-image-processing 90 | 91 | ## Authors 92 | 93 | * **Xing Han Lu** - *Initial Work* - [@xhlulu](https://github.com/xhlulu) 94 | * **Chris** - *Code Review* - [@chriddyp](https://github.com/chriddyp) 95 | 96 | See also the list of [contributors](https://github.com/plotly/dash-svm/contributors) who participated in this project. 97 | 98 | ## License 99 | 100 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 101 | 102 | ## Acknowledgments 103 | 104 | 105 | ## Screenshots 106 | ![screenshot1](images/screenshot1.png) 107 | 108 | ![screenshot2](images/screenshot2.png) 109 | -------------------------------------------------------------------------------- /notebooks/Exploring PIL Processing.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import base64\n", 10 | "from io import BytesIO\n", 11 | "import numpy as np\n", 12 | "import pandas as pd\n", 13 | "from PIL import Image, ImageFilter, ImageDraw\n", 14 | "import PIL" 15 | ] 16 | }, 17 | { 18 | "cell_type": "code", 19 | "execution_count": null, 20 | "metadata": {}, 21 | "outputs": [], 22 | "source": [ 23 | "im = Image.open('../images/IU2.jpg')\n", 24 | "arr = np.asarray(im)\n", 25 | "\n", 26 | "buff = BytesIO()\n", 27 | "im.save(buff, format='bmp')\n", 28 | "encoded = base64.b64encode(buff.getvalue()).decode(\"utf-8\")\n", 29 | "\n", 30 | "decoded = base64.b64decode(encoded)\n", 31 | "buffer = BytesIO(decoded)\n", 32 | "ret_im = Image.open(buffer)\n", 33 | "\n", 34 | "ret_arr = np.asarray(ret_im)\n", 35 | "\n", 36 | "print(np.count_nonzero(arr == ret_arr))\n", 37 | "print(np.count_nonzero(arr != ret_arr))" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": null, 43 | "metadata": { 44 | "scrolled": false 45 | }, 46 | "outputs": [], 47 | "source": [ 48 | "im_copy = im.copy()\n", 49 | "\n", 50 | "box = (1441, 1070, 2699, 1951)\n", 51 | "crop = im.crop(box)\n", 52 | "crop_mod = crop.filter(ImageFilter.FIND_EDGES)\n", 53 | "im_copy.paste(crop_mod, box)" 54 | ] 55 | }, 56 | { 57 | "cell_type": "code", 58 | "execution_count": null, 59 | "metadata": {}, 60 | "outputs": [], 61 | "source": [ 62 | "im.size" 63 | ] 64 | }, 65 | { 66 | "cell_type": "markdown", 67 | "metadata": {}, 68 | "source": [ 69 | "## Calculating Filtering Operations Speed" 70 | ] 71 | }, 72 | { 73 | "cell_type": "code", 74 | "execution_count": null, 75 | "metadata": {}, 76 | "outputs": [], 77 | "source": [ 78 | "FILTERS_DICT = {\n", 79 | " 'blur': ImageFilter.BLUR,\n", 80 | " 'contour': ImageFilter.CONTOUR,\n", 81 | " 'detail': ImageFilter.DETAIL,\n", 82 | " 'edge_enhance': ImageFilter.EDGE_ENHANCE,\n", 83 | " 'edge_enhance_more': ImageFilter.EDGE_ENHANCE_MORE,\n", 84 | " 'emboss': ImageFilter.EMBOSS,\n", 85 | " 'find_edges': ImageFilter.FIND_EDGES,\n", 86 | " 'sharpen': ImageFilter.SHARPEN,\n", 87 | " 'smooth': ImageFilter.SMOOTH,\n", 88 | " 'smooth_more': ImageFilter.SMOOTH_MORE\n", 89 | "}\n", 90 | "\n", 91 | "filters = FILTERS_DICT.values()" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": null, 97 | "metadata": {}, 98 | "outputs": [], 99 | "source": [ 100 | "im = Image.open(\"../images/cats.jpg\")\n", 101 | "\n", 102 | "for fil in filters:\n", 103 | " print(fil)\n", 104 | " %timeit im.filter(fil)\n", 105 | " print()" 106 | ] 107 | }, 108 | { 109 | "cell_type": "markdown", 110 | "metadata": {}, 111 | "source": [ 112 | "## Lasso Processing with PIL" 113 | ] 114 | }, 115 | { 116 | "cell_type": "code", 117 | "execution_count": null, 118 | "metadata": {}, 119 | "outputs": [], 120 | "source": [ 121 | "data = {'points': [], 'lassoPoints': {'x': [710.955326460481, 717.9931271477664, 724.1512027491409, 729.4295532646048, 727.6701030927835, 719.7525773195877, 702.1580756013745, 669.6082474226804, 593.9518900343643, 542.0481099656357, 483.106529209622, 417.12714776632305, 364.34364261168383, 323.87628865979383, 298.36426116838487, 281.64948453608247, 279.0103092783505, 288.6872852233677, 308.9209621993127, 330.0343642611684, 366.10309278350513, 380.1786941580756, 426.8041237113402, 491.9037800687285, 540.2886597938144, 572.8384879725086, 579.8762886597938, 584.2749140893471, 582.5154639175257, 579.8762886597938], 'y': [248.76632302405494, 254.04467353951884, 273.3986254295532, 315.62542955326455, 364.01030927835046, 386.0034364261168, 405.3573883161512, 423.8316151202749, 436.14776632302403, 437.0274914089347, 429.9896907216495, 414.1546391752577, 388.64261168384877, 356.0927835051546, 317.38487972508585, 264.6013745704467, 217.0962199312714, 173.10996563573877, 136.16151202749134, 113.28865979381436, 94.81443298969064, 91.29553264604803, 90.41580756013738, 108.89003436426108, 130.00343642611676, 151.9965635738831, 162.55326460481092, 180.147766323024, 188.94501718213053, 190.70446735395183]}}\n", 122 | "\n", 123 | "df = pd.DataFrame.from_dict(data['lassoPoints'])\n", 124 | "\n", 125 | "im = Image.open('../images/default.jpg')\n", 126 | "im.size" 127 | ] 128 | }, 129 | { 130 | "cell_type": "code", 131 | "execution_count": null, 132 | "metadata": {}, 133 | "outputs": [], 134 | "source": [ 135 | "tup = list(zip(*data['lassoPoints'].values()))\n", 136 | "\n", 137 | "new = Image.new('L', im.size)\n", 138 | "\n", 139 | "draw = ImageDraw.Draw(new)\n", 140 | "draw.polygon(tup, fill=255)" 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": null, 146 | "metadata": {}, 147 | "outputs": [], 148 | "source": [ 149 | "im.paste(im.filter(ImageFilter.BLUR), mask=new)" 150 | ] 151 | } 152 | ], 153 | "metadata": { 154 | "kernelspec": { 155 | "display_name": "Python 3", 156 | "language": "python", 157 | "name": "python3" 158 | }, 159 | "language_info": { 160 | "codemirror_mode": { 161 | "name": "ipython", 162 | "version": 3 163 | }, 164 | "file_extension": ".py", 165 | "mimetype": "text/x-python", 166 | "name": "python", 167 | "nbconvert_exporter": "python", 168 | "pygments_lexer": "ipython3", 169 | "version": "3.6.3" 170 | } 171 | }, 172 | "nbformat": 4, 173 | "nbformat_minor": 2 174 | } 175 | -------------------------------------------------------------------------------- /dash_reusable_components.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from io import BytesIO as _BytesIO 3 | import time 4 | 5 | import dash_core_components as dcc 6 | import dash_html_components as html 7 | import numpy as np 8 | 9 | import plotly.graph_objs as go 10 | from PIL import Image 11 | 12 | 13 | # Variables 14 | HTML_IMG_SRC_PARAMETERS = 'data:image/png;base64, ' 15 | 16 | 17 | # Display utility functions 18 | def _merge(a, b): 19 | return dict(a, **b) 20 | 21 | 22 | def _omit(omitted_keys, d): 23 | return {k: v for k, v in d.items() if k not in omitted_keys} 24 | 25 | 26 | # Image utility functions 27 | def pil_to_b64(im, enc_format='png', verbose=False, **kwargs): 28 | """ 29 | Converts a PIL Image into base64 string for HTML displaying 30 | :param im: PIL Image object 31 | :param enc_format: The image format for displaying. If saved the image will have that extension. 32 | :return: base64 encoding 33 | """ 34 | t_start = time.time() 35 | 36 | buff = _BytesIO() 37 | im.save(buff, format=enc_format, **kwargs) 38 | encoded = base64.b64encode(buff.getvalue()).decode("utf-8") 39 | 40 | t_end = time.time() 41 | if verbose: 42 | print(f"PIL converted to b64 in {t_end - t_start:.3f} sec") 43 | 44 | return encoded 45 | 46 | 47 | def numpy_to_b64(np_array, enc_format='png', scalar=True, **kwargs): 48 | """ 49 | Converts a numpy image into base 64 string for HTML displaying 50 | :param np_array: 51 | :param enc_format: The image format for displaying. If saved the image will have that extension. 52 | :param scalar: 53 | :return: 54 | """ 55 | # Convert from 0-1 to 0-255 56 | if scalar: 57 | np_array = np.uint8(255 * np_array) 58 | else: 59 | np_array = np.uint8(np_array) 60 | 61 | im_pil = Image.fromarray(np_array) 62 | 63 | return pil_to_b64(im_pil, enc_format, **kwargs) 64 | 65 | 66 | def b64_to_pil(string): 67 | decoded = base64.b64decode(string) 68 | buffer = _BytesIO(decoded) 69 | im = Image.open(buffer) 70 | 71 | return im 72 | 73 | 74 | def b64_to_numpy(string, to_scalar=True): 75 | im = b64_to_pil(string) 76 | np_array = np.asarray(im) 77 | 78 | if to_scalar: 79 | np_array = np_array / 255. 80 | 81 | return np_array 82 | 83 | 84 | def pil_to_bytes_string(im): 85 | """ 86 | Converts a PIL Image object into the ASCII string representation of its bytes. This is only recommended for 87 | its speed, and takes more space than any encoding. The following are sample results ran on a 3356 x 2412 88 | jpg image: 89 | (to be added) 90 | 91 | Here is the time taken to save the image as a png inside a buffer (BytesIO): 92 | Time taken to convert from b64 to PIL: 93 | 30.6 ms ± 3.58 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) 94 | 95 | Time taken to convert from PIL to b64: 96 | 1.77 s ± 66.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) 97 | 98 | Note that it CANNOT be displayed in html img tags. 99 | 100 | :param im: 101 | :return: The encoded string, and the size of the original image 102 | """ 103 | size = im.size 104 | mode = im.mode 105 | im_bytes = im.tobytes() 106 | encoding_string = base64.b64encode(im_bytes).decode('ascii') 107 | 108 | return encoding_string, size, mode 109 | 110 | 111 | def bytes_string_to_pil(encoding_string, size, mode='RGB'): 112 | """ 113 | Converts the ASCII string representation of a PIL Image bytes into the original PIL Image object. This 114 | function is only recommended for its speed, and takes more space than any encoding. The following are 115 | sample results ran on a 3356 x 2412 jpg image: 116 | (to be added) 117 | 118 | Here is the time taken to save the image as a png inside a buffer (BytesIO), then encode into b64: 119 | 120 | Time taken to convert from b64 to PIL: 121 | 30.6 ms ± 3.58 ms per loop (mean ± std. dev. of 7 runs, 10 loops each) 122 | 123 | Time taken to convert from PIL to b64: 124 | 1.77 s ± 66.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each) 125 | 126 | Note that it CANNOT be displayed in html img tags. 127 | 128 | :param encoding_string: 129 | :param size: 130 | :param mode: 131 | :return: 132 | """ 133 | if type(size) is str: 134 | size = eval(size) 135 | 136 | if type(size) not in [tuple, list]: 137 | raise ValueError("Incorrect Size type when trying to convert from bytes to PIL Image.") 138 | 139 | encoding_bytes = encoding_string.encode('ascii') 140 | decoded = base64.b64decode(encoding_bytes) 141 | 142 | im = Image.frombytes(mode, size, decoded) 143 | 144 | return im 145 | 146 | 147 | # Custom Display Components 148 | def Card(children, **kwargs): 149 | return html.Section( 150 | children, 151 | style=_merge({ 152 | 'padding': 20, 153 | 'margin': 5, 154 | 'borderRadius': 5, 155 | 'border': 'thin lightgrey solid', 156 | 157 | # Remove possibility to select the text for better UX 158 | 'user-select': 'none', 159 | '-moz-user-select': 'none', 160 | '-webkit-user-select': 'none', 161 | '-ms-user-select': 'none' 162 | }, kwargs.get('style', {})), 163 | **_omit(['style'], kwargs) 164 | ) 165 | 166 | 167 | def NamedSlider(name, id, min, max, step, value, marks=None): 168 | if marks: 169 | step = None 170 | else: 171 | marks = {i: i for i in range(min, max + 1, step)} 172 | 173 | return html.Div( 174 | style={'margin': '25px 5px 30px 0px'}, 175 | children=[ 176 | f"{name}:", 177 | 178 | html.Div( 179 | style={'margin-left': '5px'}, 180 | children=dcc.Slider( 181 | id=id, 182 | min=min, 183 | max=max, 184 | marks=marks, 185 | step=step, 186 | value=value 187 | ) 188 | ) 189 | ] 190 | ) 191 | 192 | 193 | def NamedInlineRadioItems(name, short, options, val, **kwargs): 194 | return html.Div( 195 | id=f'div-{short}', 196 | style=_merge({ 197 | 'display': 'block', 198 | 'margin-bottom': '5px', 199 | 'margin-top': '5px' 200 | }, kwargs.get('style', {})), 201 | children=[ 202 | f'{name}:', 203 | dcc.RadioItems( 204 | id=f'radio-{short}', 205 | options=options, 206 | value=val, 207 | labelStyle={ 208 | 'display': 'inline-block', 209 | 'margin-right': '7px', 210 | 'font-weight': 300 211 | }, 212 | style={ 213 | 'display': 'inline-block', 214 | 'margin-left': '7px' 215 | } 216 | ) 217 | ], 218 | **_omit(['style'], kwargs) 219 | ) 220 | 221 | 222 | # Custom Image Components 223 | def InteractiveImagePIL(image_id, 224 | image, 225 | enc_format='png', 226 | display_mode='fixed', 227 | dragmode='select', 228 | verbose=False, 229 | **kwargs): 230 | if enc_format == 'jpeg': 231 | if image.mode == 'RGBA': 232 | image = image.convert('RGB') 233 | encoded_image = pil_to_b64(image, enc_format=enc_format, verbose=verbose, quality=80) 234 | else: 235 | encoded_image = pil_to_b64(image, enc_format=enc_format, verbose=verbose) 236 | 237 | width, height = image.size 238 | 239 | if display_mode.lower() in ['scalable', 'scale']: 240 | display_height = '{}vw'.format(round(60 * height / width)) 241 | else: 242 | display_height = '80vh' 243 | 244 | return dcc.Graph( 245 | id=image_id, 246 | figure={ 247 | 'data': [], 248 | 'layout': { 249 | 'margin': go.Margin(l=40, b=40, t=26, r=10), 250 | 'xaxis': { 251 | 'range': (0, width), 252 | 'scaleanchor': 'y', 253 | 'scaleratio': 1 254 | }, 255 | 'yaxis': { 256 | 'range': (0, height) 257 | }, 258 | 'images': [{ 259 | 'xref': 'x', 260 | 'yref': 'y', 261 | 'x': 0, 262 | 'y': 0, 263 | 'yanchor': 'bottom', 264 | 'sizing': 'stretch', 265 | 'sizex': width, 266 | 'sizey': height, 267 | 'layer': 'below', 268 | 'source': HTML_IMG_SRC_PARAMETERS + encoded_image, 269 | }], 270 | 'dragmode': dragmode, 271 | } 272 | }, 273 | style=_merge({ 274 | 'height': display_height, 275 | 'width': '100%' 276 | }, kwargs.get('style', {})), 277 | 278 | config={ 279 | 'modeBarButtonsToRemove': [ 280 | 'sendDataToCloud', 281 | 'autoScale2d', 282 | 'toggleSpikelines', 283 | 'hoverClosestCartesian', 284 | 'hoverCompareCartesian', 285 | 'zoom2d' 286 | ] 287 | }, 288 | 289 | **_omit(['style'], kwargs) 290 | ) 291 | 292 | 293 | def DisplayImagePIL(id, image, **kwargs): 294 | encoded_image = pil_to_b64(image, enc_format='png') 295 | 296 | return html.Img( 297 | id=f'img-{id}', 298 | src=HTML_IMG_SRC_PARAMETERS + encoded_image, 299 | width='100%', 300 | **kwargs 301 | ) 302 | 303 | 304 | def CustomDropdown(**kwargs): 305 | return html.Div( 306 | dcc.Dropdown(**kwargs), 307 | style={'margin-top': '5px', 'margin-bottom': '5px'} 308 | ) 309 | -------------------------------------------------------------------------------- /assets/dash_template.css: -------------------------------------------------------------------------------- 1 | /* 2 | Gist: https://gist.github.com/xhlulu/0acba79000a3fd1e6f552ed82edb8a64/ 3 | Production: https://cdn.rawgit.com/xhlulu/0acba79000a3fd1e6f552ed82edb8a64/raw/dash_template.css 4 | Development: https://rawgit.com/xhlulu/0acba79000a3fd1e6f552ed82edb8a64/raw/dash_template.css 5 | */ 6 | 7 | 8 | /* Table of contents 9 | –––––––––––––––––––––––––––––––––––––––––––––––––– 10 | - Banner 11 | - Grid 12 | - Base Styles 13 | - Typography 14 | - Links 15 | - Buttons 16 | - Forms 17 | - Lists 18 | - Code 19 | - Tables 20 | - Spacing 21 | - Utilities 22 | - Clearing 23 | - Media Queries 24 | */ 25 | 26 | 27 | 28 | /* Banner 29 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 30 | .banner { 31 | height: 75px; 32 | background-color: #414073; 33 | border-radius: 5px 5px 2px 2px; 34 | box-shadow: rgb(240, 240, 240) 5px 5px 5px 0px; 35 | padding-top: 0px; 36 | padding-left: 0px; 37 | padding-right: 0px; 38 | width: 90%; 39 | margin-left: auto; 40 | margin-right: auto; 41 | } 42 | 43 | .banner h2{ 44 | color: white; 45 | margin-left: 1.5%; 46 | display: inline-block; 47 | font-family: 'Open Sans', sans-serif; 48 | font-size:4rem; 49 | line-height:1; 50 | } 51 | 52 | .banner Img{ 53 | position: relative; 54 | float: right; 55 | right: 1.5%; 56 | height: 75px; 57 | } 58 | 59 | 60 | /* Grid 61 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 62 | .container { 63 | position: relative; 64 | background-color: white; 65 | border-radius: 2px 2px 5px 5px; 66 | font-size: 1.5rem; 67 | box-shadow: rgb(240, 240, 240) 5px 5px 5px 0px; 68 | border: thin solid rgb(240, 240, 240); 69 | margin-left: auto; 70 | margin-right: auto; 71 | color: #302F54; 72 | padding: 8px; 73 | width: 90%; 74 | max-width: none; 75 | box-sizing: border-box; 76 | } 77 | 78 | .column, 79 | .columns { 80 | width: 100%; 81 | float: left; 82 | box-sizing: border-box; } 83 | 84 | 85 | /* For devices larger than 550px */ 86 | @media (min-width: 550px) { 87 | .column, 88 | .columns { 89 | margin-left: 0.5%; } 90 | .column:first-child, 91 | .columns:first-child { 92 | margin-left: 0; } 93 | 94 | .one.column, 95 | .one.columns { width: 8%; } 96 | .two.columns { width: 16.25%; } 97 | .three.columns { width: 22%; } 98 | .four.columns { width: 33%; } 99 | .five.columns { width: 39.3333333333%; } 100 | .six.columns { width: 49.75%; } 101 | .seven.columns { width: 56.6666666667%; } 102 | .eight.columns { width: 66.5%; } 103 | .nine.columns { width: 74.0%; } 104 | .ten.columns { width: 82.6666666667%; } 105 | .eleven.columns { width: 91.5%; } 106 | .twelve.columns { width: 100%; margin-left: 0; } 107 | 108 | .one-third.column { width: 30.6666666667%; } 109 | .two-thirds.column { width: 65.3333333333%; } 110 | 111 | .one-half.column { width: 48%; } 112 | 113 | /* Offsets */ 114 | .offset-by-one.column, 115 | .offset-by-one.columns { margin-left: 8.66666666667%; } 116 | .offset-by-two.column, 117 | .offset-by-two.columns { margin-left: 17.3333333333%; } 118 | .offset-by-three.column, 119 | .offset-by-three.columns { margin-left: 26%; } 120 | .offset-by-four.column, 121 | .offset-by-four.columns { margin-left: 34.6666666667%; } 122 | .offset-by-five.column, 123 | .offset-by-five.columns { margin-left: 43.3333333333%; } 124 | .offset-by-six.column, 125 | .offset-by-six.columns { margin-left: 52%; } 126 | .offset-by-seven.column, 127 | .offset-by-seven.columns { margin-left: 60.6666666667%; } 128 | .offset-by-eight.column, 129 | .offset-by-eight.columns { margin-left: 69.3333333333%; } 130 | .offset-by-nine.column, 131 | .offset-by-nine.columns { margin-left: 78.0%; } 132 | .offset-by-ten.column, 133 | .offset-by-ten.columns { margin-left: 86.6666666667%; } 134 | .offset-by-eleven.column, 135 | .offset-by-eleven.columns { margin-left: 95.3333333333%; } 136 | 137 | .offset-by-one-third.column, 138 | .offset-by-one-third.columns { margin-left: 34.6666666667%; } 139 | .offset-by-two-thirds.column, 140 | .offset-by-two-thirds.columns { margin-left: 69.3333333333%; } 141 | 142 | .offset-by-one-half.column, 143 | .offset-by-one-half.columns { margin-left: 52%; } 144 | 145 | } 146 | 147 | 148 | /* Base Styles 149 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 150 | /* NOTE 151 | html is set to 62.5% so that all the REM measurements throughout Skeleton 152 | are based on 10px sizing. So basically 1.5rem = 15px :) */ 153 | html { 154 | font-size: 62.5%; } 155 | body { 156 | font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */ 157 | line-height: 1.6; 158 | font-weight: 400; 159 | font-family: 'Roboto', sans-serif; 160 | color: #222; } 161 | 162 | 163 | /* Typography 164 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 165 | h1, h2, h3, h4, h5, h6 { 166 | margin-top: 0; 167 | margin-bottom: 0; 168 | font-weight: 300; } 169 | h1 { font-size: 4.5rem; line-height: 1.2; letter-spacing: -.1rem; margin-bottom: 2rem; } 170 | h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; margin-bottom: 1.8rem; margin-top: 1.8rem;} 171 | h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; margin-bottom: 1.5rem; margin-top: 1.5rem;} 172 | h4 { font-size: 2.6rem; line-height: 1.35; letter-spacing: -.08rem; margin-bottom: 1.2rem; margin-top: 1.2rem;} 173 | h5 { font-size: 2.2rem; line-height: 1.5; letter-spacing: -.05rem; margin-bottom: 0.6rem; margin-top: 0.6rem;} 174 | h6 { font-size: 2.0rem; line-height: 1.6; letter-spacing: 0; margin-bottom: 0.75rem; margin-top: 0.75rem;} 175 | 176 | p { 177 | margin-top: 0; } 178 | 179 | 180 | /* Blockquotes 181 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 182 | blockquote { 183 | border-left: 4px lightgrey solid; 184 | padding-left: 1rem; 185 | margin-top: 2rem; 186 | margin-bottom: 2rem; 187 | margin-left: 0rem; 188 | } 189 | 190 | 191 | /* Links 192 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 193 | a { 194 | color: #1EAEDB; } 195 | a:hover { 196 | color: #0FA0CE; } 197 | 198 | 199 | /* Buttons 200 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 201 | .button, 202 | button, 203 | input[type="submit"], 204 | input[type="reset"], 205 | input[type="button"] { 206 | display: inline-block; 207 | height: 38px; 208 | padding: 0 30px; 209 | color: #555; 210 | text-align: center; 211 | font-size: 11px; 212 | font-weight: 600; 213 | line-height: 38px; 214 | letter-spacing: .1rem; 215 | text-transform: uppercase; 216 | text-decoration: none; 217 | white-space: nowrap; 218 | background-color: transparent; 219 | border-radius: 4px; 220 | border: 1px solid #bbb; 221 | cursor: pointer; 222 | box-sizing: border-box; } 223 | .button:hover, 224 | button:hover, 225 | input[type="submit"]:hover, 226 | input[type="reset"]:hover, 227 | input[type="button"]:hover, 228 | .button:focus, 229 | button:focus, 230 | input[type="submit"]:focus, 231 | input[type="reset"]:focus, 232 | input[type="button"]:focus { 233 | color: #333; 234 | border-color: #888; 235 | outline: 0; } 236 | .button.button-primary, 237 | button.button-primary, 238 | input[type="submit"].button-primary, 239 | input[type="reset"].button-primary, 240 | input[type="button"].button-primary { 241 | color: #FFF; 242 | background-color: #33C3F0; 243 | border-color: #33C3F0; } 244 | .button.button-primary:hover, 245 | button.button-primary:hover, 246 | input[type="submit"].button-primary:hover, 247 | input[type="reset"].button-primary:hover, 248 | input[type="button"].button-primary:hover, 249 | .button.button-primary:focus, 250 | button.button-primary:focus, 251 | input[type="submit"].button-primary:focus, 252 | input[type="reset"].button-primary:focus, 253 | input[type="button"].button-primary:focus { 254 | color: #FFF; 255 | background-color: #1EAEDB; 256 | border-color: #1EAEDB; } 257 | 258 | 259 | /* Forms 260 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 261 | input[type="email"], 262 | input[type="number"], 263 | input[type="search"], 264 | input[type="text"], 265 | input[type="tel"], 266 | input[type="url"], 267 | input[type="password"], 268 | textarea, 269 | select { 270 | height: 38px; 271 | padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */ 272 | background-color: #fff; 273 | border: 1px solid #D1D1D1; 274 | border-radius: 4px; 275 | box-shadow: none; 276 | box-sizing: border-box; 277 | font-family: inherit; 278 | font-size: inherit; /*https://stackoverflow.com/questions/6080413/why-doesnt-input-inherit-the-font-from-body*/} 279 | /* Removes awkward default styles on some inputs for iOS */ 280 | input[type="email"], 281 | input[type="number"], 282 | input[type="search"], 283 | input[type="text"], 284 | input[type="tel"], 285 | input[type="url"], 286 | input[type="password"], 287 | textarea { 288 | -webkit-appearance: none; 289 | -moz-appearance: none; 290 | appearance: none; } 291 | textarea { 292 | min-height: 65px; 293 | padding-top: 6px; 294 | padding-bottom: 6px; } 295 | input[type="email"]:focus, 296 | input[type="number"]:focus, 297 | input[type="search"]:focus, 298 | input[type="text"]:focus, 299 | input[type="tel"]:focus, 300 | input[type="url"]:focus, 301 | input[type="password"]:focus, 302 | textarea:focus, 303 | select:focus { 304 | border: 1px solid #33C3F0; 305 | outline: 0; } 306 | label, 307 | legend { 308 | display: block; 309 | margin-bottom: 0px; } 310 | fieldset { 311 | padding: 0; 312 | border-width: 0; } 313 | input[type="checkbox"], 314 | input[type="radio"] { 315 | display: inline; } 316 | label > .label-body { 317 | display: inline-block; 318 | margin-left: .5rem; 319 | font-weight: normal; } 320 | 321 | 322 | /* Lists 323 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 324 | ul { 325 | list-style: circle inside; } 326 | ol { 327 | list-style: decimal inside; } 328 | ol, ul { 329 | padding-left: 0; 330 | margin-top: 0; } 331 | ul ul, 332 | ul ol, 333 | ol ol, 334 | ol ul { 335 | margin: 1.5rem 0 1.5rem 3rem; 336 | font-size: 90%; } 337 | li { 338 | margin-bottom: 1rem; } 339 | 340 | 341 | /* Tables 342 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 343 | th, 344 | td { 345 | padding: 12px 15px; 346 | text-align: left; 347 | border-bottom: 1px solid #E1E1E1; } 348 | th:first-child, 349 | td:first-child { 350 | padding-left: 0; } 351 | th:last-child, 352 | td:last-child { 353 | padding-right: 0; } 354 | 355 | 356 | /* Spacing 357 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 358 | button, 359 | .button { 360 | margin-bottom: 0rem; } 361 | input, 362 | textarea, 363 | select, 364 | fieldset { 365 | margin-bottom: 0rem; } 366 | pre, 367 | dl, 368 | figure, 369 | table, 370 | form { 371 | margin-bottom: 0rem; } 372 | p, 373 | ul, 374 | ol { 375 | margin-bottom: 0.75rem; } 376 | 377 | /* Utilities 378 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 379 | .u-full-width { 380 | width: 100%; 381 | box-sizing: border-box; } 382 | .u-max-full-width { 383 | max-width: 100%; 384 | box-sizing: border-box; } 385 | .u-pull-right { 386 | float: right; } 387 | .u-pull-left { 388 | float: left; } 389 | 390 | 391 | /* Misc 392 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 393 | hr { 394 | margin-top: 3rem; 395 | margin-bottom: 3.5rem; 396 | border-width: 0; 397 | border-top: 1px solid #E1E1E1; } 398 | 399 | 400 | /* Clearing 401 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 402 | 403 | /* Self Clearing Goodness */ 404 | .container:after, 405 | .row:after, 406 | .u-cf { 407 | content: ""; 408 | display: table; 409 | clear: both; } 410 | 411 | 412 | /* Media Queries 413 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 414 | /* 415 | Note: The best way to structure the use of media queries is to create the queries 416 | near the relevant code. For example, if you wanted to change the styles for buttons 417 | on small devices, paste the mobile query code up in the buttons section and style it 418 | there. 419 | */ 420 | 421 | 422 | /* Larger than mobile */ 423 | @media (min-width: 400px) {} 424 | 425 | /* Larger than phablet (also point when grid becomes active) */ 426 | @media (min-width: 550px) {} 427 | 428 | /* Larger than tablet */ 429 | @media (min-width: 750px) {} 430 | 431 | /* Larger than desktop */ 432 | @media (min-width: 1000px) {} 433 | 434 | /* Larger than Desktop HD */ 435 | @media (min-width: 1200px) {} -------------------------------------------------------------------------------- /notebooks/Image Components Functions Tests.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import os\n", 10 | "import sys\n", 11 | "import base64\n", 12 | "from io import BytesIO\n", 13 | "\n", 14 | "import numpy as np\n", 15 | "from PIL import Image\n", 16 | "\n", 17 | "sys.path.append(\"..\")\n", 18 | "from dash_reusable_components import *\n", 19 | "\n", 20 | "# Displays images smaller\n", 21 | "def display(im, new_width=400):\n", 22 | " ratio = new_width / im.size[0]\n", 23 | " new_height = round(im.size[1] * ratio)\n", 24 | " return im.resize((new_width, new_height))" 25 | ] 26 | }, 27 | { 28 | "cell_type": "markdown", 29 | "metadata": {}, 30 | "source": [ 31 | "## Testing PIL vs b64" 32 | ] 33 | }, 34 | { 35 | "cell_type": "code", 36 | "execution_count": null, 37 | "metadata": { 38 | "scrolled": false 39 | }, 40 | "outputs": [], 41 | "source": [ 42 | "image_path = \"../images/IU.jpg\"\n", 43 | "\n", 44 | "im = Image.open(image_path)\n", 45 | "print(\"Shape of Image:\", im.size)\n", 46 | "print(\"Size of Image:\", os.stat(image_path).st_size, \"bytes\")\n", 47 | "display(im)" 48 | ] 49 | }, 50 | { 51 | "cell_type": "markdown", 52 | "metadata": {}, 53 | "source": [ 54 | "### Encoding" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": null, 60 | "metadata": { 61 | "scrolled": false 62 | }, 63 | "outputs": [], 64 | "source": [ 65 | "enc_png = pil_to_b64(im)\n", 66 | "print(\"PNG results:\")\n", 67 | "print(\"Length of string:\", len(enc_png))\n", 68 | "print(\"Size of string:\", sys.getsizeof(enc_png), \"bytes\")\n", 69 | "print(\"Time taken to convert from PIL to b64:\")\n", 70 | "%timeit pil_to_b64(im)\n", 71 | "\n", 72 | "enc_jpg = pil_to_b64(im, enc_format='jpeg')\n", 73 | "print(\"\\nJPEG results:\")\n", 74 | "print(\"Length of string:\", len(enc_jpg))\n", 75 | "print(\"Size of string:\", sys.getsizeof(enc_jpg), \"bytes\")\n", 76 | "print(\"Time taken to convert from PIL to b64:\")\n", 77 | "%timeit pil_to_b64(im, enc_format='jpeg')" 78 | ] 79 | }, 80 | { 81 | "cell_type": "markdown", 82 | "metadata": {}, 83 | "source": [ 84 | "### Decoding" 85 | ] 86 | }, 87 | { 88 | "cell_type": "code", 89 | "execution_count": null, 90 | "metadata": {}, 91 | "outputs": [], 92 | "source": [ 93 | "dec_png = b64_to_pil(enc_png)\n", 94 | "print(\"PNG results:\")\n", 95 | "print(\"Time taken to convert from b64 to PIL:\")\n", 96 | "%timeit b64_to_pil(enc_png)\n", 97 | "\n", 98 | "dec_jpg = b64_to_pil(enc_jpg)\n", 99 | "print(\"\\nJPEG results:\")\n", 100 | "print(\"Time taken to convert from b64 to PIL:\")\n", 101 | "%timeit b64_to_pil(enc_jpg)" 102 | ] 103 | }, 104 | { 105 | "cell_type": "code", 106 | "execution_count": null, 107 | "metadata": { 108 | "scrolled": false 109 | }, 110 | "outputs": [], 111 | "source": [ 112 | "decoded = b64_to_pil(enc_png)\n", 113 | "display(decoded)" 114 | ] 115 | }, 116 | { 117 | "cell_type": "markdown", 118 | "metadata": {}, 119 | "source": [ 120 | "## Testing Numpy and b64" 121 | ] 122 | }, 123 | { 124 | "cell_type": "markdown", 125 | "metadata": {}, 126 | "source": [ 127 | "### Encoding" 128 | ] 129 | }, 130 | { 131 | "cell_type": "code", 132 | "execution_count": null, 133 | "metadata": { 134 | "scrolled": true 135 | }, 136 | "outputs": [], 137 | "source": [ 138 | "# Get numpy array from previous image\n", 139 | "np_array = np.asarray(im)\n", 140 | "print(\"Numpy array shape:\", np_array.shape)\n", 141 | "print(\"Numpy array size:\", np_array.nbytes, \"bytes\")\n", 142 | "\n", 143 | "enc_png = numpy_to_b64(im, scalar=False, enc_format='png')\n", 144 | "print(\"\\nPNG results:\")\n", 145 | "print(\"Length of string:\", len(enc_png))\n", 146 | "print(\"Size of string:\", sys.getsizeof(enc_png), \"bytes\")\n", 147 | "print(\"Time taken to convert from Numpy to b64:\")\n", 148 | "%timeit numpy_to_b64(im, scalar=False)\n", 149 | "\n", 150 | "enc_jpg = numpy_to_b64(im, scalar=False, enc_format='jpeg')\n", 151 | "print(\"\\nJPEG results:\")\n", 152 | "print(\"Length of string:\", len(enc_jpg))\n", 153 | "print(\"Size of string:\", sys.getsizeof(enc_jpg), \"bytes\")\n", 154 | "print(\"Time taken to convert from Numpy to b64:\")\n", 155 | "%timeit numpy_to_b64(im, scalar=False, enc_format='jpeg')" 156 | ] 157 | }, 158 | { 159 | "cell_type": "markdown", 160 | "metadata": {}, 161 | "source": [ 162 | "### Decoding" 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": null, 168 | "metadata": {}, 169 | "outputs": [], 170 | "source": [ 171 | "dec_png = b64_to_numpy(enc_png, to_scalar=False)\n", 172 | "print(\"PNG results:\")\n", 173 | "print(\"Time taken to convert from b64 to Numpy:\")\n", 174 | "%timeit b64_to_numpy(enc_png)\n", 175 | "print(\"Time taken to convert from b64 to Numpy (to_scalar false):\")\n", 176 | "%timeit b64_to_numpy(enc_png, to_scalar=False)\n", 177 | "\n", 178 | "\n", 179 | "dec_jpg = b64_to_numpy(enc_jpg, to_scalar=False)\n", 180 | "print(\"\\nJPEG results:\")\n", 181 | "print(\"Time taken to convert from b64 to Numpy:\")\n", 182 | "%timeit b64_to_numpy(enc_jpg)\n", 183 | "print(\"Time taken to convert from b64 to Numpy (to_scalar false):\")\n", 184 | "%timeit b64_to_numpy(enc_jpg, to_scalar=False)" 185 | ] 186 | }, 187 | { 188 | "cell_type": "markdown", 189 | "metadata": {}, 190 | "source": [ 191 | "## Testing PIL and Bytes Encoding/Decoding" 192 | ] 193 | }, 194 | { 195 | "cell_type": "code", 196 | "execution_count": null, 197 | "metadata": {}, 198 | "outputs": [], 199 | "source": [ 200 | "print(\"Time taken to convert from PIL to bytes string:\")\n", 201 | "%timeit pil_to_bytes_string(im)\n", 202 | "\n", 203 | "enc_b, im_size, mode = pil_to_bytes_string(im)\n", 204 | "\n", 205 | "print(\"\\nTime taken to convert from bytes string to PIL:\")\n", 206 | "%timeit bytes_string_to_pil(enc_b, im_size)" 207 | ] 208 | }, 209 | { 210 | "cell_type": "markdown", 211 | "metadata": {}, 212 | "source": [ 213 | "### Compare Matching for Jpeg and png encodings" 214 | ] 215 | }, 216 | { 217 | "cell_type": "code", 218 | "execution_count": null, 219 | "metadata": { 220 | "scrolled": false 221 | }, 222 | "outputs": [], 223 | "source": [ 224 | "print(\"dec_png and np_array are same:\", np.all(dec_png == np_array))\n", 225 | "print(\"dec_jpg and np_array are same:\", np.all(dec_jpg == np_array))\n", 226 | "\n", 227 | "matching_count = np.count_nonzero(dec_jpg == np_array)\n", 228 | "non_matching_count = np.count_nonzero(dec_jpg != np_array)\n", 229 | "total = matching_count + non_matching_count\n", 230 | "\n", 231 | "print(\"\\nNumber of matching values:\", matching_count)\n", 232 | "print(\"Number of non-matching values:\", non_matching_count)\n", 233 | "print(f\"{100 * matching_count / total:.2f}% matching vs {100 * non_matching_count / total:.2f}% not matching\")\n", 234 | "\n", 235 | "display(Image.fromarray(dec_jpg))" 236 | ] 237 | }, 238 | { 239 | "cell_type": "markdown", 240 | "metadata": {}, 241 | "source": [ 242 | "## Conversion speed at different dimensions" 243 | ] 244 | }, 245 | { 246 | "cell_type": "markdown", 247 | "metadata": {}, 248 | "source": [ 249 | "### PIL to b64" 250 | ] 251 | }, 252 | { 253 | "cell_type": "code", 254 | "execution_count": null, 255 | "metadata": {}, 256 | "outputs": [], 257 | "source": [ 258 | "heights = [360, 480, 720, 1080, 2160]\n", 259 | "\n", 260 | "for height in heights:\n", 261 | " width = round(height * 16 / 9)\n", 262 | " resized_im = im.resize((width, height))\n", 263 | " \n", 264 | " print(f\"Size: {width}x{height}\")\n", 265 | " print(\"Time taken to convert from PIL to b64 (png):\")\n", 266 | " %timeit pil_to_b64(resized_im, enc_format='png')\n", 267 | " print(\"Time taken to convert from PIL to b64 (jpeg):\")\n", 268 | " %timeit pil_to_b64(resized_im, enc_format='jpeg')\n", 269 | " print()" 270 | ] 271 | }, 272 | { 273 | "cell_type": "markdown", 274 | "metadata": {}, 275 | "source": [ 276 | "### Numpy to b64" 277 | ] 278 | }, 279 | { 280 | "cell_type": "code", 281 | "execution_count": null, 282 | "metadata": {}, 283 | "outputs": [], 284 | "source": [ 285 | "heights = [360, 480, 720, 1080, 2160]\n", 286 | "\n", 287 | "for height in heights:\n", 288 | " width = round(height * 16 / 9)\n", 289 | " resized_im = im.resize((width, height))\n", 290 | " \n", 291 | " print(f\"Size: {width}x{height}\")\n", 292 | " print(\"Time taken to convert from numpy to b64 (png):\")\n", 293 | " %timeit numpy_to_b64(resized_im, scalar=False)\n", 294 | " print(\"Time taken to convert from numpy to b64 (jpeg):\")\n", 295 | " %timeit numpy_to_b64(resized_im, enc_format='jpeg', scalar=False)\n", 296 | " print()" 297 | ] 298 | }, 299 | { 300 | "cell_type": "code", 301 | "execution_count": null, 302 | "metadata": {}, 303 | "outputs": [], 304 | "source": [ 305 | "buff = BytesIO()\n", 306 | "%timeit im.save(buff, format='png', compression_level=1)\n", 307 | "%timeit encoded = base64.b64encode(buff.getvalue())" 308 | ] 309 | }, 310 | { 311 | "cell_type": "markdown", 312 | "metadata": {}, 313 | "source": [ 314 | "## Exploring Jpeg Compression" 315 | ] 316 | }, 317 | { 318 | "cell_type": "code", 319 | "execution_count": null, 320 | "metadata": {}, 321 | "outputs": [], 322 | "source": [ 323 | "dec_jpg.filter(ImageFilter.BLUR).size" 324 | ] 325 | }, 326 | { 327 | "cell_type": "code", 328 | "execution_count": null, 329 | "metadata": { 330 | "scrolled": false 331 | }, 332 | "outputs": [], 333 | "source": [ 334 | "from PIL import ImageFilter\n", 335 | "\n", 336 | "im = Image.open('../images/cats.jpg')\n", 337 | "np_array = np.asarray(im)\n", 338 | "\n", 339 | "for x in range(1, 11):\n", 340 | " enc_jpg = pil_to_b64(im, enc_format='jpeg', quality=100)\n", 341 | " dec_jpg = b64_to_pil(enc_jpg)\n", 342 | " \n", 343 | " random = np.random.randint(0, 1500)\n", 344 | " # Apply some operation\n", 345 | " box = (random, random, random + 50, random + 50)\n", 346 | " cropped = dec_jpg.filter(ImageFilter.BLUR).crop(box)\n", 347 | " dec_jpg.paste(cropped, box=box)\n", 348 | " \n", 349 | " dec_arr = np.asarray(dec_jpg)\n", 350 | " \n", 351 | " matching_count = np.count_nonzero(dec_arr == np_array)\n", 352 | " non_matching_count = np.count_nonzero(dec_arr != np_array)\n", 353 | " total = matching_count + non_matching_count\n", 354 | "\n", 355 | " print(f\"\\nNumber of matching values after {x} compressions: {matching_count}\")\n", 356 | " print(\"Number of non-matching values:\", non_matching_count)\n", 357 | " print(f\"{100 * matching_count / total:.2f}% matching vs {100 * non_matching_count / total:.2f}% not matching\")" 358 | ] 359 | }, 360 | { 361 | "cell_type": "markdown", 362 | "metadata": {}, 363 | "source": [ 364 | "### Exploring Lossless jpeg compression (jpeg 2000)" 365 | ] 366 | }, 367 | { 368 | "cell_type": "code", 369 | "execution_count": null, 370 | "metadata": {}, 371 | "outputs": [], 372 | "source": [ 373 | "def pil_to_b64(im, enc_format='png', verbose=False, **kwargs):\n", 374 | " \"\"\"\n", 375 | " Converts a PIL Image into base64 string for HTML displaying\n", 376 | " :param im: PIL Image object\n", 377 | " :param enc_format: The image format for displaying. If saved the image will have that extension.\n", 378 | " :return: base64 encoding\n", 379 | " \"\"\"\n", 380 | " t_start = time.time()\n", 381 | "\n", 382 | " buff = BytesIO()\n", 383 | " im.save(buff, format=enc_format, **kwargs)\n", 384 | " encoded = base64.b64encode(buff.getvalue()).decode(\"utf-8\")\n", 385 | "\n", 386 | " t_end = time.time()\n", 387 | " if verbose:\n", 388 | " print(f\"PIL converted to b64 in {t_end - t_start:.3f} sec\")\n", 389 | "\n", 390 | " return encoded" 391 | ] 392 | }, 393 | { 394 | "cell_type": "code", 395 | "execution_count": null, 396 | "metadata": {}, 397 | "outputs": [], 398 | "source": [ 399 | "%timeit pil_to_b64(im, enc_format='png')" 400 | ] 401 | }, 402 | { 403 | "cell_type": "code", 404 | "execution_count": null, 405 | "metadata": {}, 406 | "outputs": [], 407 | "source": [ 408 | "%timeit pil_to_b64(im, enc_format='jpeg2000')" 409 | ] 410 | }, 411 | { 412 | "cell_type": "code", 413 | "execution_count": null, 414 | "metadata": {}, 415 | "outputs": [], 416 | "source": [ 417 | "%timeit pil_to_b64(im, enc_format='jpeg')" 418 | ] 419 | }, 420 | { 421 | "cell_type": "markdown", 422 | "metadata": {}, 423 | "source": [ 424 | "### Exploring Jpeg compression Sizes" 425 | ] 426 | }, 427 | { 428 | "cell_type": "code", 429 | "execution_count": null, 430 | "metadata": {}, 431 | "outputs": [], 432 | "source": [ 433 | "%timeit pil_to_b64(im, enc_format='jpeg', quality=100)\n", 434 | "%timeit pil_to_b64(im, enc_format='jpeg', quality=95)" 435 | ] 436 | }, 437 | { 438 | "cell_type": "code", 439 | "execution_count": null, 440 | "metadata": { 441 | "scrolled": true 442 | }, 443 | "outputs": [], 444 | "source": [ 445 | "im = Image.open('../images/cats.jpg')\n", 446 | "print(len(pil_to_b64(im, enc_format='jpeg', quality=90)))\n", 447 | "print(len(pil_to_b64(im, enc_format='jpeg', quality=95)))\n", 448 | "print(len(pil_to_b64(im, enc_format='jpeg', quality=100)))" 449 | ] 450 | }, 451 | { 452 | "cell_type": "markdown", 453 | "metadata": {}, 454 | "source": [ 455 | "## Supplementary Exploration" 456 | ] 457 | }, 458 | { 459 | "cell_type": "code", 460 | "execution_count": null, 461 | "metadata": {}, 462 | "outputs": [], 463 | "source": [ 464 | "import pandas as pd\n", 465 | "im = Image.open('../images/IU2.jpg')\n", 466 | "arr = np.asarray(im)\n", 467 | "\n", 468 | "print(arr.size)\n", 469 | "\n", 470 | "%timeit im.getdata()\n", 471 | "%timeit pil_to_b64(im)\n", 472 | "%timeit Image.fromarray(arr)" 473 | ] 474 | }, 475 | { 476 | "cell_type": "code", 477 | "execution_count": null, 478 | "metadata": {}, 479 | "outputs": [], 480 | "source": [ 481 | "barr = arr.tobytes()\n", 482 | "back = np.frombuffer(barr, dtype=np.uint8).reshape(arr.shape)\n", 483 | "display(Image.fromarray(back))" 484 | ] 485 | }, 486 | { 487 | "cell_type": "code", 488 | "execution_count": null, 489 | "metadata": {}, 490 | "outputs": [], 491 | "source": [ 492 | "%timeit barr = np.asarray(im).tobytes()\n", 493 | "%timeit Image.fromarray(np.frombuffer(barr, dtype=np.uint8).reshape(arr.shape))" 494 | ] 495 | }, 496 | { 497 | "cell_type": "code", 498 | "execution_count": null, 499 | "metadata": {}, 500 | "outputs": [], 501 | "source": [ 502 | "%timeit imgSize = im.size\n", 503 | "%timeit rawData = im.tobytes()\n", 504 | "%timeit Image.frombytes('RGB', imgSize, rawData)" 505 | ] 506 | }, 507 | { 508 | "cell_type": "code", 509 | "execution_count": null, 510 | "metadata": {}, 511 | "outputs": [], 512 | "source": [ 513 | "im = Image.open('../images/IU2.jpg')\n", 514 | "imgSize = im.size\n", 515 | "imb = im.tobytes()\n", 516 | "enc_str = base64.b64encode(imb).decode('ascii')\n", 517 | "\n", 518 | "dec = base64.b64decode(enc_str.encode('ascii'))\n", 519 | "display(Image.frombytes('RGB', imgSize, dec))" 520 | ] 521 | }, 522 | { 523 | "cell_type": "code", 524 | "execution_count": null, 525 | "metadata": { 526 | "scrolled": true 527 | }, 528 | "outputs": [], 529 | "source": [ 530 | "im = Image.open('../images/IU2.jpg')\n", 531 | "arr = np.asarray(im)\n", 532 | "arrb = arr.tobytes()\n", 533 | "enc_str = base64.b64encode(barr).decode('ascii')\n", 534 | "imgSize = arr.shape\n", 535 | "\n", 536 | "dec = base64.b64decode(enc_str.encode('ascii'))\n", 537 | "retrieved_arr = np.frombuffer(barr, dtype=np.uint8).reshape(imgSize)\n", 538 | "\n", 539 | "im_retrieved = Image.fromarray(retrieved_arr)\n", 540 | "print(type(im_retrieved))\n", 541 | "display(im_retrieved)" 542 | ] 543 | }, 544 | { 545 | "cell_type": "code", 546 | "execution_count": null, 547 | "metadata": {}, 548 | "outputs": [], 549 | "source": [ 550 | "%timeit pil_to_b64(im, enc_format='bmp')\n", 551 | "string = pil_to_b64(im, enc_format='bmp')\n", 552 | "%timeit b64_to_pil(string)" 553 | ] 554 | }, 555 | { 556 | "cell_type": "code", 557 | "execution_count": null, 558 | "metadata": {}, 559 | "outputs": [], 560 | "source": [ 561 | "# Image utility functions\n", 562 | "def pil_to_b64_png(im, verbose=False, comp=6):\n", 563 | " \"\"\"\n", 564 | " Converts a PIL Image into base64 string for HTML displaying\n", 565 | " :param im: PIL Image object\n", 566 | " :param enc_format: The image format for displaying. If saved the image will have that extension.\n", 567 | " :return: base64 encoding\n", 568 | " \"\"\"\n", 569 | " t_start = time.time()\n", 570 | "\n", 571 | " buff = BytesIO()\n", 572 | " im.save(buff, format='png', compress_level=comp)\n", 573 | " encoded = base64.b64encode(buff.getvalue()).decode(\"utf-8\")\n", 574 | "\n", 575 | " t_end = time.time()\n", 576 | " if verbose:\n", 577 | " print(f\"PIL converted to b64 in {t_end - t_start:.3f} sec\")\n", 578 | "\n", 579 | " return encoded\n", 580 | "\n", 581 | "%timeit pil_to_b64_png(im, comp=1)\n", 582 | "string = pil_to_b64_png(im, comp=1)\n", 583 | "%timeit b64_to_pil(string)" 584 | ] 585 | }, 586 | { 587 | "cell_type": "code", 588 | "execution_count": null, 589 | "metadata": {}, 590 | "outputs": [], 591 | "source": [ 592 | "def func(im):\n", 593 | " buff = BytesIO()\n", 594 | " im.save(buff, format='png', compress_level=1)\n", 595 | " \n", 596 | "%timeit func(im)" 597 | ] 598 | } 599 | ], 600 | "metadata": { 601 | "kernelspec": { 602 | "display_name": "Python 3", 603 | "language": "python", 604 | "name": "python3" 605 | }, 606 | "language_info": { 607 | "codemirror_mode": { 608 | "name": "ipython", 609 | "version": 3 610 | }, 611 | "file_extension": ".py", 612 | "mimetype": "text/x-python", 613 | "name": "python", 614 | "nbconvert_exporter": "python", 615 | "pygments_lexer": "ipython3", 616 | "version": "3.6.3" 617 | } 618 | }, 619 | "nbformat": 4, 620 | "nbformat_minor": 2 621 | } 622 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | import uuid 5 | from copy import deepcopy 6 | 7 | import boto3 8 | import dash 9 | import dash_core_components as dcc 10 | import dash_html_components as html 11 | import requests 12 | from dash.dependencies import Input, Output, State 13 | from dotenv import load_dotenv, find_dotenv 14 | from flask_caching import Cache 15 | 16 | import dash_reusable_components as drc 17 | from utils import STORAGE_PLACEHOLDER, GRAPH_PLACEHOLDER, \ 18 | IMAGE_STRING_PLACEHOLDER 19 | from utils import apply_filters, show_histogram, generate_lasso_mask, \ 20 | apply_enhancements 21 | 22 | DEBUG = True 23 | 24 | app = dash.Dash(__name__) 25 | server = app.server 26 | 27 | 28 | if 'REDIS_URL' in os.environ: 29 | # Change caching to redis if hosted on dds 30 | cache_config = { 31 | 'CACHE_TYPE': 'redis', 32 | 'CACHE_REDIS_URL': os.environ["REDIS_URL"], 33 | 'CACHE_THRESHOLD': 400 34 | } 35 | # Local Conditions 36 | else: 37 | # Make sure that your credentials are saved inside your .env file, as 38 | # given here: 39 | # https://devcenter.heroku.com/articles/bucketeer#environment-setup 40 | load_dotenv(find_dotenv()) 41 | 42 | # Caching with filesystem when served locally 43 | cache_config = { 44 | 'CACHE_TYPE': 'filesystem', 45 | 'CACHE_DIR': 'cache-directory', 46 | } 47 | 48 | # S3 Client. It is used to store user images. The bucket name 49 | # is stored inside the utils file, the key is 50 | # the session id generated by uuid 51 | access_key_id = os.environ.get('ACCESS_KEY_ID') 52 | secret_access_key = os.environ.get('SECRET_ACCESS_KEY') 53 | bucket_name = os.environ.get('BUCKET_NAME') 54 | 55 | s3 = boto3.client('s3', 56 | endpoint_url="https://storage.googleapis.com", 57 | aws_access_key_id=access_key_id, 58 | aws_secret_access_key=secret_access_key) 59 | 60 | # Caching 61 | cache = Cache() 62 | cache.init_app(app.server, config=cache_config) 63 | 64 | 65 | def store_image_string(string, key_name): 66 | # Generate the POST attributes 67 | post = s3.generate_presigned_post( 68 | Bucket=bucket_name, 69 | Key=key_name 70 | ) 71 | 72 | files = {"file": string} 73 | # Post the string file using requests 74 | response = requests.post(post["url"], data=post["fields"], files=files) 75 | return response 76 | 77 | 78 | def serve_layout(): 79 | # Generates a session ID 80 | session_id = str(uuid.uuid4()) 81 | 82 | # Post the image to the right key, inside the bucket named after the 83 | # session ID 84 | res = store_image_string(IMAGE_STRING_PLACEHOLDER, session_id) 85 | print(res) 86 | 87 | # App Layout 88 | return html.Div([ 89 | # Session ID 90 | html.Div(session_id, id='session-id', style={'display': 'none'}), 91 | 92 | # Banner display 93 | html.Div([ 94 | html.H2( 95 | 'Image Processing App', 96 | id='title' 97 | ), 98 | html.Img( 99 | src="https://s3-us-west-1.amazonaws.com/plotly-tutorials/logo/new-branding/dash-logo-by-plotly-stripe-inverted.png" 100 | ) 101 | ], 102 | className="banner" 103 | ), 104 | 105 | # Body 106 | html.Div(className="container", children=[ 107 | html.Div(className='row', children=[ 108 | html.Div(className='five columns', children=[ 109 | drc.Card([ 110 | dcc.Upload( 111 | id='upload-image', 112 | children=[ 113 | 'Drag and Drop or ', 114 | html.A('Select an Image') 115 | ], 116 | style={ 117 | 'width': '100%', 118 | 'height': '50px', 119 | 'lineHeight': '50px', 120 | 'borderWidth': '1px', 121 | 'borderStyle': 'dashed', 122 | 'borderRadius': '5px', 123 | 'textAlign': 'center' 124 | }, 125 | accept='image/*' 126 | ), 127 | 128 | drc.NamedInlineRadioItems( 129 | name='Selection Mode', 130 | short='selection-mode', 131 | options=[ 132 | {'label': ' Rectangular', 'value': 'select'}, 133 | {'label': ' Lasso', 'value': 'lasso'} 134 | ], 135 | val='select' 136 | ), 137 | 138 | drc.NamedInlineRadioItems( 139 | name='Image Display Format', 140 | short='encoding-format', 141 | options=[ 142 | {'label': ' JPEG', 'value': 'jpeg'}, 143 | {'label': ' PNG', 'value': 'png'} 144 | ], 145 | val='jpeg' 146 | ), 147 | ]), 148 | 149 | drc.Card([ 150 | drc.CustomDropdown( 151 | id='dropdown-filters', 152 | options=[ 153 | {'label': 'Blur', 'value': 'blur'}, 154 | {'label': 'Contour', 'value': 'contour'}, 155 | {'label': 'Detail', 'value': 'detail'}, 156 | {'label': 'Enhance Edge', 'value': 'edge_enhance'}, 157 | {'label': 'Enhance Edge (More)', 'value': 'edge_enhance_more'}, 158 | {'label': 'Emboss', 'value': 'emboss'}, 159 | {'label': 'Find Edges', 'value': 'find_edges'}, 160 | {'label': 'Sharpen', 'value': 'sharpen'}, 161 | {'label': 'Smooth', 'value': 'smooth'}, 162 | {'label': 'Smooth (More)', 163 | 'value': 'smooth_more'} 164 | ], 165 | searchable=False, 166 | placeholder='Basic Filter...' 167 | ), 168 | 169 | drc.CustomDropdown( 170 | id='dropdown-enhance', 171 | options=[ 172 | {'label': 'Brightness', 'value': 'brightness'}, 173 | {'label': 'Color Balance', 'value': 'color'}, 174 | {'label': 'Contrast', 'value': 'contrast'}, 175 | {'label': 'Sharpness', 'value': 'sharpness'} 176 | ], 177 | searchable=False, 178 | placeholder='Enhance...' 179 | ), 180 | 181 | html.Div( 182 | id='div-enhancement-factor', 183 | style={ 184 | 'display': 'none', 185 | 'margin': '25px 5px 30px 0px' 186 | }, 187 | children=[ 188 | f"Enhancement Factor:", 189 | html.Div( 190 | style={'margin-left': '5px'}, 191 | children=dcc.Slider( 192 | id='slider-enhancement-factor', 193 | min=0, 194 | max=2, 195 | step=0.1, 196 | value=1, 197 | updatemode='drag' 198 | ) 199 | ) 200 | ] 201 | ), 202 | 203 | html.Button( 204 | 'Run Operation', 205 | id='button-run-operation', 206 | style={'margin-right': '10px', 'margin-top': '5px'} 207 | ), 208 | 209 | html.Button( 210 | 'Undo', 211 | id='button-undo', 212 | style={'margin-top': '5px'} 213 | ) 214 | ]), 215 | 216 | dcc.Graph(id='graph-histogram-colors', 217 | config={'displayModeBar': False}) 218 | ]), 219 | 220 | html.Div( 221 | className='seven columns', 222 | style={'float': 'right'}, 223 | children=[ 224 | # The Interactive Image Div contains the dcc Graph 225 | # showing the image, as well as the hidden div storing 226 | # the true image 227 | html.Div(id='div-interactive-image', children=[ 228 | GRAPH_PLACEHOLDER, 229 | html.Div( 230 | id='div-storage', 231 | children=STORAGE_PLACEHOLDER, 232 | style={'display': 'none'} 233 | ) 234 | ]) 235 | ] 236 | ) 237 | ]) 238 | ]) 239 | ]) 240 | 241 | 242 | app.layout = serve_layout 243 | 244 | 245 | # Helper functions for callbacks 246 | def add_action_to_stack(action_stack, 247 | operation, 248 | operation_type, 249 | selectedData): 250 | """ 251 | Add new action to the action stack, in-place. 252 | :param action_stack: The stack of action that are applied to an image 253 | :param operation: The operation that is applied to the image 254 | :param operation_type: The type of the operation, which could be a filter, 255 | an enhancement, etc. 256 | :param selectedData: The JSON object that contains the zone selected by 257 | the user in which the operation is applied 258 | :return: None, appending is done in place 259 | """ 260 | 261 | new_action = { 262 | 'operation': operation, 263 | 'type': operation_type, 264 | 'selectedData': selectedData 265 | } 266 | 267 | action_stack.append(new_action) 268 | 269 | 270 | def undo_last_action(n_clicks, storage): 271 | action_stack = storage['action_stack'] 272 | 273 | if n_clicks is None: 274 | storage['undo_click_count'] = 0 275 | 276 | # If the stack isn't empty and the undo click count has changed 277 | elif len(action_stack) > 0 and n_clicks > storage['undo_click_count']: 278 | # Remove the last action on the stack 279 | action_stack.pop() 280 | 281 | # Update the undo click count 282 | storage['undo_click_count'] = n_clicks 283 | 284 | return storage 285 | 286 | 287 | # Recursively retrieve the previous versions of the image by popping the 288 | # action stack 289 | @cache.memoize() 290 | def apply_actions_on_image(session_id, 291 | action_stack, 292 | filename, 293 | image_signature): 294 | action_stack = deepcopy(action_stack) 295 | 296 | # If we have arrived to the original image 297 | if len(action_stack) == 0: 298 | # Retrieve the url in which the image string is stored inside s3, 299 | # using the session ID 300 | 301 | url = s3.generate_presigned_url( 302 | ClientMethod='get_object', 303 | Params={ 304 | 'Bucket': bucket_name, 305 | 'Key': session_id 306 | } 307 | ) 308 | 309 | # A key replacement is required for URL pre-sign in gcp 310 | 311 | url = url.replace('AWSAccessKeyId', 'GoogleAccessId') 312 | 313 | response = requests.get(url) 314 | print(len(response.text)) 315 | im_pil = drc.b64_to_pil(response.text) 316 | return im_pil 317 | 318 | # Pop out the last action 319 | last_action = action_stack.pop() 320 | # Apply all the previous action_stack, and gets the image PIL 321 | im_pil = apply_actions_on_image( 322 | session_id, 323 | action_stack, 324 | filename, 325 | image_signature 326 | ) 327 | im_size = im_pil.size 328 | 329 | # Apply the rest of the action_stack 330 | operation = last_action['operation'] 331 | selectedData = last_action['selectedData'] 332 | type = last_action['type'] 333 | 334 | # Select using Lasso 335 | if selectedData and 'lassoPoints' in selectedData: 336 | selection_mode = 'lasso' 337 | selection_zone = generate_lasso_mask(im_pil, selectedData) 338 | # Select using rectangular box 339 | elif selectedData and 'range' in selectedData: 340 | selection_mode = 'select' 341 | lower, upper = map(int, selectedData['range']['y']) 342 | left, right = map(int, selectedData['range']['x']) 343 | # Adjust height difference 344 | height = im_size[1] 345 | upper = height - upper 346 | lower = height - lower 347 | selection_zone = (left, upper, right, lower) 348 | # Select the whole image 349 | else: 350 | selection_mode = 'select' 351 | selection_zone = (0, 0) + im_size 352 | 353 | # Apply the filters 354 | if type == 'filter': 355 | apply_filters( 356 | image=im_pil, 357 | zone=selection_zone, 358 | filter=operation, 359 | mode=selection_mode 360 | ) 361 | elif type == 'enhance': 362 | enhancement = operation['enhancement'] 363 | factor = operation['enhancement_factor'] 364 | 365 | apply_enhancements( 366 | image=im_pil, 367 | zone=selection_zone, 368 | enhancement=enhancement, 369 | enhancement_factor=factor, 370 | mode=selection_mode 371 | ) 372 | 373 | return im_pil 374 | 375 | 376 | @app.callback(Output('interactive-image', 'figure'), 377 | [Input('radio-selection-mode', 'value')], 378 | [State('interactive-image', 'figure')]) 379 | def update_selection_mode(selection_mode, figure): 380 | if figure: 381 | figure['layout']['dragmode'] = selection_mode 382 | return figure 383 | 384 | 385 | @app.callback(Output('graph-histogram-colors', 'figure'), 386 | [Input('interactive-image', 'figure')]) 387 | def update_histogram(figure): 388 | # Retrieve the image stored inside the figure 389 | enc_str = figure['layout']['images'][0]['source'].split(';base64,')[-1] 390 | # Creates the PIL Image object from the b64 png encoding 391 | im_pil = drc.b64_to_pil(string=enc_str) 392 | 393 | return show_histogram(im_pil) 394 | 395 | 396 | @app.callback(Output('div-interactive-image', 'children'), 397 | [Input('upload-image', 'contents'), 398 | Input('button-undo', 'n_clicks'), 399 | Input('button-run-operation', 'n_clicks')], 400 | [State('interactive-image', 'selectedData'), 401 | State('dropdown-filters', 'value'), 402 | State('dropdown-enhance', 'value'), 403 | State('slider-enhancement-factor', 'value'), 404 | State('upload-image', 'filename'), 405 | State('radio-selection-mode', 'value'), 406 | State('radio-encoding-format', 'value'), 407 | State('div-storage', 'children'), 408 | State('session-id', 'children')]) 409 | def update_graph_interactive_image(content, 410 | undo_clicks, 411 | n_clicks, 412 | selectedData, 413 | filters, 414 | enhance, 415 | enhancement_factor, 416 | new_filename, 417 | dragmode, 418 | enc_format, 419 | storage, 420 | session_id): 421 | t_start = time.time() 422 | 423 | # Retrieve information saved in storage, which is a dict containing 424 | # information about the image and its action stack 425 | storage = json.loads(storage) 426 | filename = storage['filename'] # Filename is the name of the image file. 427 | image_signature = storage['image_signature'] 428 | 429 | # Runs the undo function if the undo button was clicked. Storage stays 430 | # the same otherwise. 431 | storage = undo_last_action(undo_clicks, storage) 432 | 433 | # If a new file was uploaded (new file name changed) 434 | if new_filename and new_filename != filename: 435 | # Replace filename 436 | if DEBUG: 437 | print(filename, "replaced by", new_filename) 438 | 439 | # Update the storage dict 440 | storage['filename'] = new_filename 441 | 442 | # Parse the string and convert to pil 443 | string = content.split(';base64,')[-1] 444 | im_pil = drc.b64_to_pil(string) 445 | 446 | # Update the image signature, which is the first 200 b64 characters 447 | # of the string encoding 448 | storage['image_signature'] = string[:200] 449 | 450 | # Posts the image string into the Bucketeer Storage (which is hosted 451 | # on S3) 452 | store_image_string(string, session_id) 453 | if DEBUG: 454 | print(new_filename, "added to Bucketeer S3.") 455 | 456 | # Resets the action stack 457 | storage['action_stack'] = [] 458 | 459 | # If an operation was applied (when the filename wasn't changed) 460 | else: 461 | # Add actions to the action stack (we have more than one if filters 462 | # and enhance are BOTH selected) 463 | if filters: 464 | type = 'filter' 465 | operation = filters 466 | add_action_to_stack( 467 | storage['action_stack'], 468 | operation, 469 | type, 470 | selectedData 471 | ) 472 | 473 | if enhance: 474 | type = 'enhance' 475 | operation = { 476 | 'enhancement': enhance, 477 | 'enhancement_factor': enhancement_factor, 478 | } 479 | add_action_to_stack( 480 | storage['action_stack'], 481 | operation, 482 | type, 483 | selectedData 484 | ) 485 | 486 | # Apply the required actions to the picture, using memoized function 487 | im_pil = apply_actions_on_image( 488 | session_id, 489 | storage['action_stack'], 490 | filename, 491 | image_signature 492 | ) 493 | 494 | t_end = time.time() 495 | if DEBUG: 496 | print(f"Updated Image Storage in {t_end - t_start:.3f} sec") 497 | 498 | return [ 499 | drc.InteractiveImagePIL( 500 | image_id='interactive-image', 501 | image=im_pil, 502 | enc_format=enc_format, 503 | display_mode='fixed', 504 | dragmode=dragmode, 505 | verbose=DEBUG 506 | ), 507 | 508 | html.Div( 509 | id='div-storage', 510 | children=json.dumps(storage), 511 | style={'display': 'none'} 512 | ) 513 | ] 514 | 515 | 516 | # Show/Hide Callbacks 517 | @app.callback(Output('div-enhancement-factor', 'style'), 518 | [Input('dropdown-enhance', 'value')], 519 | [State('div-enhancement-factor', 'style')]) 520 | def show_slider_enhancement_factor(value, style): 521 | # If any enhancement is selected 522 | if value: 523 | style['display'] = 'block' 524 | else: 525 | style['display'] = 'none' 526 | 527 | return style 528 | 529 | 530 | # Reset Callbacks 531 | @app.callback(Output('dropdown-filters', 'value'), 532 | [Input('button-run-operation', 'n_clicks')]) 533 | def reset_dropdown_filters(_): 534 | return None 535 | 536 | 537 | @app.callback(Output('dropdown-enhance', 'value'), 538 | [Input('button-run-operation', 'n_clicks')]) 539 | def reset_dropdown_enhance(_): 540 | return None 541 | 542 | 543 | # Running the server 544 | if __name__ == '__main__': 545 | app.run_server(debug=True) 546 | -------------------------------------------------------------------------------- /assets/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.7.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left{margin-right:.3em}.fa.fa-pull-right{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-resistance:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-y-combinator-square:before,.fa-yc-square:before,.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-intersex:before,.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-genderless:before{content:"\f22d"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"}.fa-yc:before,.fa-y-combinator:before{content:"\f23b"}.fa-optin-monster:before{content:"\f23c"}.fa-opencart:before{content:"\f23d"}.fa-expeditedssl:before{content:"\f23e"}.fa-battery-4:before,.fa-battery:before,.fa-battery-full:before{content:"\f240"}.fa-battery-3:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-battery-2:before,.fa-battery-half:before{content:"\f242"}.fa-battery-1:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-mouse-pointer:before{content:"\f245"}.fa-i-cursor:before{content:"\f246"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-sticky-note:before{content:"\f249"}.fa-sticky-note-o:before{content:"\f24a"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-diners-club:before{content:"\f24c"}.fa-clone:before{content:"\f24d"}.fa-balance-scale:before{content:"\f24e"}.fa-hourglass-o:before{content:"\f250"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-hourglass-2:before,.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass:before{content:"\f254"}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:"\f255"}.fa-hand-stop-o:before,.fa-hand-paper-o:before{content:"\f256"}.fa-hand-scissors-o:before{content:"\f257"}.fa-hand-lizard-o:before{content:"\f258"}.fa-hand-spock-o:before{content:"\f259"}.fa-hand-pointer-o:before{content:"\f25a"}.fa-hand-peace-o:before{content:"\f25b"}.fa-trademark:before{content:"\f25c"}.fa-registered:before{content:"\f25d"}.fa-creative-commons:before{content:"\f25e"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-tripadvisor:before{content:"\f262"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-get-pocket:before{content:"\f265"}.fa-wikipedia-w:before{content:"\f266"}.fa-safari:before{content:"\f267"}.fa-chrome:before{content:"\f268"}.fa-firefox:before{content:"\f269"}.fa-opera:before{content:"\f26a"}.fa-internet-explorer:before{content:"\f26b"}.fa-tv:before,.fa-television:before{content:"\f26c"}.fa-contao:before{content:"\f26d"}.fa-500px:before{content:"\f26e"}.fa-amazon:before{content:"\f270"}.fa-calendar-plus-o:before{content:"\f271"}.fa-calendar-minus-o:before{content:"\f272"}.fa-calendar-times-o:before{content:"\f273"}.fa-calendar-check-o:before{content:"\f274"}.fa-industry:before{content:"\f275"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-map-o:before{content:"\f278"}.fa-map:before{content:"\f279"}.fa-commenting:before{content:"\f27a"}.fa-commenting-o:before{content:"\f27b"}.fa-houzz:before{content:"\f27c"}.fa-vimeo:before{content:"\f27d"}.fa-black-tie:before{content:"\f27e"}.fa-fonticons:before{content:"\f280"}.fa-reddit-alien:before{content:"\f281"}.fa-edge:before{content:"\f282"}.fa-credit-card-alt:before{content:"\f283"}.fa-codiepie:before{content:"\f284"}.fa-modx:before{content:"\f285"}.fa-fort-awesome:before{content:"\f286"}.fa-usb:before{content:"\f287"}.fa-product-hunt:before{content:"\f288"}.fa-mixcloud:before{content:"\f289"}.fa-scribd:before{content:"\f28a"}.fa-pause-circle:before{content:"\f28b"}.fa-pause-circle-o:before{content:"\f28c"}.fa-stop-circle:before{content:"\f28d"}.fa-stop-circle-o:before{content:"\f28e"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-hashtag:before{content:"\f292"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-percent:before{content:"\f295"}.fa-gitlab:before{content:"\f296"}.fa-wpbeginner:before{content:"\f297"}.fa-wpforms:before{content:"\f298"}.fa-envira:before{content:"\f299"}.fa-universal-access:before{content:"\f29a"}.fa-wheelchair-alt:before{content:"\f29b"}.fa-question-circle-o:before{content:"\f29c"}.fa-blind:before{content:"\f29d"}.fa-audio-description:before{content:"\f29e"}.fa-volume-control-phone:before{content:"\f2a0"}.fa-braille:before{content:"\f2a1"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asl-interpreting:before,.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-deafness:before,.fa-hard-of-hearing:before,.fa-deaf:before{content:"\f2a4"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-signing:before,.fa-sign-language:before{content:"\f2a7"}.fa-low-vision:before{content:"\f2a8"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-pied-piper:before{content:"\f2ae"}.fa-first-order:before{content:"\f2b0"}.fa-yoast:before{content:"\f2b1"}.fa-themeisle:before{content:"\f2b2"}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:"\f2b3"}.fa-fa:before,.fa-font-awesome:before{content:"\f2b4"}.fa-handshake-o:before{content:"\f2b5"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-o:before{content:"\f2b7"}.fa-linode:before{content:"\f2b8"}.fa-address-book:before{content:"\f2b9"}.fa-address-book-o:before{content:"\f2ba"}.fa-vcard:before,.fa-address-card:before{content:"\f2bb"}.fa-vcard-o:before,.fa-address-card-o:before{content:"\f2bc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-circle-o:before{content:"\f2be"}.fa-user-o:before{content:"\f2c0"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-drivers-license-o:before,.fa-id-card-o:before{content:"\f2c3"}.fa-quora:before{content:"\f2c4"}.fa-free-code-camp:before{content:"\f2c5"}.fa-telegram:before{content:"\f2c6"}.fa-thermometer-4:before,.fa-thermometer:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-shower:before{content:"\f2cc"}.fa-bathtub:before,.fa-s15:before,.fa-bath:before{content:"\f2cd"}.fa-podcast:before{content:"\f2ce"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-times-rectangle:before,.fa-window-close:before{content:"\f2d3"}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:"\f2d4"}.fa-bandcamp:before{content:"\f2d5"}.fa-grav:before{content:"\f2d6"}.fa-etsy:before{content:"\f2d7"}.fa-imdb:before{content:"\f2d8"}.fa-ravelry:before{content:"\f2d9"}.fa-eercast:before{content:"\f2da"}.fa-microchip:before{content:"\f2db"}.fa-snowflake-o:before{content:"\f2dc"}.fa-superpowers:before{content:"\f2dd"}.fa-wpexplorer:before{content:"\f2de"}.fa-meetup:before{content:"\f2e0"}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} 5 | --------------------------------------------------------------------------------