├── .nvmrc ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── mstile-150x150.png ├── android-chrome-192x192.png ├── browserconfig.xml ├── manifest.json ├── index.html └── safari-pinned-tab.svg ├── src ├── images │ ├── logo.png │ ├── baboon.jpg │ ├── bugatti.jpg │ ├── lenna.jpg │ ├── peppers.jpg │ └── headscarf.jpg ├── index.js ├── actions │ ├── aboutActions.js │ └── imageActions.js ├── reducers │ ├── aboutReducer.js │ └── imageReducer.js ├── components │ ├── Checkbox.js │ ├── SketchItButton.js │ ├── UploadPrompt.js │ ├── BrowserWarning.js │ ├── ImagePane.js │ ├── Header.js │ ├── SketchedImage.js │ ├── Slider.js │ ├── UploadZone.js │ ├── SettingsForm.js │ ├── ImageButtons.js │ ├── SettingsPane.js │ ├── PresetImages.js │ └── AboutModal.js ├── store.js ├── App.js ├── App.css └── xdog.js ├── .gitignore ├── package.json ├── LICENSE.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v8.9.1 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/src/images/logo.png -------------------------------------------------------------------------------- /src/images/baboon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/src/images/baboon.jpg -------------------------------------------------------------------------------- /src/images/bugatti.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/src/images/bugatti.jpg -------------------------------------------------------------------------------- /src/images/lenna.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/src/images/lenna.jpg -------------------------------------------------------------------------------- /src/images/peppers.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/src/images/peppers.jpg -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /src/images/headscarf.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/src/images/headscarf.jpg -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexpeattie/xdog-sketch/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render(, document.getElementById('root')) -------------------------------------------------------------------------------- /src/actions/aboutActions.js: -------------------------------------------------------------------------------- 1 | export const OPEN_MODAL = 'OPEN_MODAL' 2 | export const CLOSE_MODAL = 'CLOSE_MODAL' 3 | 4 | export function openModal() { 5 | return { 6 | type: OPEN_MODAL 7 | } 8 | } 9 | 10 | export function closeModal() { 11 | return { 12 | type: CLOSE_MODAL 13 | } 14 | } -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #00aba9 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "XDoG Sketch", 3 | "icons": [ 4 | { 5 | "src": "/android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image/png" 8 | } 9 | ], 10 | "theme_color": "#ffffff", 11 | "background_color": "#ffffff", 12 | "display": "standalone" 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /src/reducers/aboutReducer.js: -------------------------------------------------------------------------------- 1 | import { OPEN_MODAL, CLOSE_MODAL } from '../actions/aboutActions' 2 | 3 | export default function reducer(state = { 4 | visible: false 5 | }, action) { 6 | switch (action.type) { 7 | case OPEN_MODAL: { 8 | return { visible: true } 9 | } 10 | case CLOSE_MODAL: { 11 | return { visible: false } 12 | } 13 | default: { 14 | return state 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/Checkbox.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Field } from 'redux-form' 3 | 4 | const Checkbox = props => { 5 | const { name, label } = props 6 | 7 | return ( 8 |
9 | 13 |
14 | ) 15 | } 16 | export default Checkbox -------------------------------------------------------------------------------- /src/components/SketchItButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { submit } from 'redux-form' 4 | import cx from 'classnames' 5 | 6 | const SketchItButton = props => { 7 | const sketchIt = () => { 8 | props.dispatch(submit('imageSettings')) 9 | } 10 | 11 | const buttonStyle = cx('btn', 'btn-block', { loading: props.image.rerendering }) 12 | 13 | return ( 14 | 17 | ) 18 | } 19 | 20 | export default connect(({ image }) => ({ image }))(SketchItButton) -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from 'redux' 2 | import { reducer as formReducer } from 'redux-form' 3 | import image from "./reducers/imageReducer" 4 | import about from "./reducers/aboutReducer" 5 | import { composeWithDevTools } from 'redux-devtools-extension' 6 | import thunk from 'redux-thunk' 7 | 8 | const rootReducer = combineReducers({ 9 | form: formReducer, 10 | image, 11 | about 12 | }) 13 | 14 | const middleware = composeWithDevTools(applyMiddleware(thunk)) 15 | 16 | const store = createStore( 17 | rootReducer, 18 | middleware 19 | ) 20 | export default store -------------------------------------------------------------------------------- /src/components/UploadPrompt.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import cx from 'classnames' 3 | 4 | const UploadPrompt = props => { 5 | const { loading } = props 6 | 7 | return ( 8 |
9 |
10 | 11 |
12 |

Upload a picture

13 |

Drag and drop or select a file.

14 |
15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | export default UploadPrompt -------------------------------------------------------------------------------- /src/components/BrowserWarning.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { detect } from 'detect-browser' 4 | 5 | const Warning = styled.div` 6 | background: rgba(232, 86, 0, .9); 7 | border-color: #e85600; 8 | padding: 0.5rem 2rem; 9 | color: #fff; 10 | text-align: center; 11 | width: 100%; 12 | margin: 0 auto; 13 | min-width: 1000px; 14 | ` 15 | 16 | const BrowserWarning = props => { 17 | const { name, version } = (detect() || {}) 18 | const isRecentChrome = (name === 'chrome' && parseFloat(version) > 50) 19 | 20 | return !isRecentChrome && ( 21 | It's recommended you use a recent version of Chrome with this tool; other browsers haven't been tested and may cause unexpected results. 22 | ) 23 | } 24 | 25 | export default BrowserWarning -------------------------------------------------------------------------------- /src/components/ImagePane.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import PresetImages from './PresetImages' 4 | import UploadZone from './UploadZone' 5 | import SketchedImage from './SketchedImage' 6 | import UploadPrompt from './UploadPrompt' 7 | import ImageButtons from './ImageButtons' 8 | 9 | const ImagePane = props => { 10 | const { width, height, url, originalUrl, sketched } = props.image 11 | const imageChosen = (url && url.length) 12 | 13 | return ( 14 |
15 | 16 | { imageChosen ? : } 17 | 18 | 19 | { imageChosen ? : } 20 |
21 | ) 22 | } 23 | 24 | export default connect(({ image }) => ({ image }))(ImagePane) -------------------------------------------------------------------------------- /src/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import logo from '../images/logo.png' 4 | import { connect } from 'react-redux' 5 | import { openModal } from '../actions/aboutActions' 6 | 7 | const HeaderContainer = styled.div` 8 | margin: 1.5rem auto 2.5rem; 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | ` 13 | 14 | const LogoImage = styled.img` 15 | height: 50px; 16 | ` 17 | 18 | const AboutButton = styled.button` 19 | flex-grow: 0; 20 | ` 21 | 22 | const Header = props => { 23 | return ( 24 | 25 | 26 | props.dispatch(openModal()) } className='btn'> 27 | About 28 | 29 | 30 | ) 31 | } 32 | export default connect()(Header) -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Provider } from 'react-redux' 3 | import Header from './components/Header' 4 | import ImagePane from './components/ImagePane' 5 | import SettingsPane from './components/SettingsPane' 6 | import BrowserWarning from './components/BrowserWarning' 7 | import AboutModal from './components/AboutModal' 8 | import './App.css'; 9 | import store from './store' 10 | 11 | class App extends Component { 12 | render() { 13 | return ( 14 | 15 |
16 | 17 | 18 |
19 |
20 |
21 | 22 | 23 |
24 | 25 |
26 |
27 |
28 | ); 29 | } 30 | } 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /src/components/SketchedImage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const ImageContainer = styled.div` 5 | display: flex; 6 | justify-content: center; 7 | border-bottom: 1px solid #eee; 8 | box-shadow: 0px 2px 11px -5px; 9 | ` 10 | 11 | const Image = styled.img` 12 | max-width: 100%; 13 | ` 14 | 15 | const SketchedImage = props => { 16 | const { width, height, url, originalUrl, sketched } = props 17 | 18 | return ( 19 | 20 |
21 |
22 | 23 |
24 | 25 | { sketched && ( 26 |
27 | 28 | 29 |
) } 30 |
31 |
32 | ) 33 | } 34 | 35 | export default SketchedImage -------------------------------------------------------------------------------- /src/components/Slider.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Field } from 'redux-form' 3 | import numeral from 'numeral' 4 | import cx from 'classnames' 5 | 6 | const renderSlider = props => { 7 | const { min, max, step, disabled } = props 8 | const format = props.format || '0.00' 9 | 10 | return field => () 11 | } 12 | 13 | const Slider = props => { 14 | const { min, max, step, name, label, disabled, format } = props 15 | const labelClass = cx('form-label', { disabled }) 16 | 17 | return ( 18 |
19 |
20 | 23 |
24 |
25 | 26 |
27 |
28 | ) 29 | } 30 | export default Slider -------------------------------------------------------------------------------- /src/reducers/imageReducer.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_IMAGE_URL, UPDATE_SOURCE_PIXELS, RERENDERING, CLEAR_IMAGE, LOAD_NEW_IMAGE_PENDING } from '../actions/imageActions' 2 | 3 | const initial = { 4 | pixels: null, 5 | url: null, 6 | width: null, 7 | height: null, 8 | originalWidth: null, 9 | originalHeight: null, 10 | sketched: false, 11 | rerendering: false, 12 | options: null, 13 | filename: null, 14 | loading: false 15 | } 16 | 17 | export default function reducer(state = initial, action) { 18 | switch (action.type) { 19 | case UPDATE_IMAGE_URL: { 20 | return { ...state, ...action.payload, rerendering: false } 21 | } 22 | case UPDATE_SOURCE_PIXELS: { 23 | const { pixels } = action.payload 24 | return { ...state, pixels, rerendering: false, sketched: false, loading: false } 25 | } 26 | case CLEAR_IMAGE: { 27 | return { ...initial } 28 | } 29 | case RERENDERING: { 30 | return { ...state, rerendering: true } 31 | } 32 | case LOAD_NEW_IMAGE_PENDING: { 33 | return { ...state, loading: true } 34 | } 35 | default: { 36 | return state 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xdog", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "basename": "^0.1.2", 7 | "classnames": "^2.2.5", 8 | "deeplearn": "^0.3.11", 9 | "detect-browser": "^2.0.0", 10 | "gaussian-convolution-kernel": "^1.0.0", 11 | "get-pixels": "^3.3.0", 12 | "ndarray": "^1.0.18", 13 | "numeral": "^2.0.6", 14 | "promise-file-reader": "^1.0.0", 15 | "react": "^16.1.1", 16 | "react-dom": "^16.1.1", 17 | "react-dropzone": "^4.2.3", 18 | "react-redux": "^5.0.6", 19 | "react-scripts": "1.0.17", 20 | "redux": "^3.7.2", 21 | "redux-form": "^7.1.2", 22 | "redux-thunk": "^2.2.0", 23 | "save-pixels": "^2.3.4", 24 | "spectre.css": "^0.4.6", 25 | "stream-to-promise": "^2.2.0", 26 | "styled-components": "^2.2.3", 27 | "zeros": "^1.0.0" 28 | }, 29 | "scripts": { 30 | "start": "PORT=3067 react-scripts start", 31 | "build": "react-scripts build", 32 | "test": "react-scripts test --env=jsdom", 33 | "eject": "react-scripts eject" 34 | }, 35 | "devDependencies": { 36 | "redux-devtools-extension": "^2.13.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2018 Alex Peattie 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/components/UploadZone.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import Dropzone from 'react-dropzone' 4 | import { readAsDataURL } from 'promise-file-reader' 5 | import { loadNewImage } from '../actions/imageActions' 6 | 7 | const theme = { 8 | style: { 9 | transition: 'box-shadow 0.3s ease' 10 | }, 11 | acceptStyle: { 12 | boxShadow: '0 0 8px -1px #32b643' 13 | } 14 | } 15 | 16 | let dropzoneRef 17 | 18 | class UploadZone extends Component { 19 | handleDrop = files => { 20 | const [image, ...rest] = files // eslint-disable-line no-unused-vars 21 | 22 | readAsDataURL(image).then(dataUrl => { 23 | this.props.dispatch(loadNewImage(dataUrl, image.name)) 24 | }) 25 | } 26 | 27 | browse = () => { 28 | dropzoneRef && dropzoneRef.open() 29 | } 30 | 31 | render() { 32 | 33 | return ( 34 | { dropzoneRef = node }} onDrop={ this.handleDrop } multiple={ false } disableClick={ true } { ...theme }> 35 | { React.cloneElement(this.props.children, { browse: this.browse, loading: this.props.loading }) } 36 | 37 | ) 38 | } 39 | } 40 | 41 | export default connect(({ image }) => ({ loading: image.loading }))(UploadZone) -------------------------------------------------------------------------------- /src/components/SettingsForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { reduxForm, formValueSelector } from 'redux-form' 4 | import Slider from './Slider' 5 | import Checkbox from './Checkbox' 6 | 7 | let SettingsForm = props => { 8 | const { handleSubmit, XDoGEnabled } = props 9 | 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | const initialValues = { 27 | sigmaOne: 1, 28 | sigmaTwo: 1.8, 29 | threshold: 0.4, 30 | gpuAccelerated: true, 31 | XDoG: true, 32 | sharpen: 35, 33 | phi: 0.1, 34 | epsilon: 20 35 | } 36 | 37 | SettingsForm = reduxForm({ 38 | form: 'imageSettings', 39 | initialValues 40 | })(SettingsForm) 41 | 42 | const selector = formValueSelector('imageSettings') 43 | SettingsForm = connect(state => { 44 | return { 45 | XDoGEnabled: selector(state, 'XDoG'), 46 | } 47 | })(SettingsForm) 48 | 49 | export default SettingsForm -------------------------------------------------------------------------------- /src/components/ImageButtons.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { clearImage } from '../actions/imageActions' 4 | import basename from 'basename' 5 | import styled from 'styled-components' 6 | 7 | const ActionsStrip = styled.div` 8 | margin-top: 1rem; 9 | text-align: left; 10 | display: flex; 11 | justify-content: space-between; 12 | width: 100%; 13 | align-items: flex-start; 14 | padding: 0 0.4rem; 15 | ` 16 | 17 | const Parameters = styled.p` 18 | margin-bottom: 0.4rem; 19 | font-size: 14px; 20 | ` 21 | 22 | const DownloadButton = styled.a` 23 | margin-left: 0.4rem; 24 | i { 25 | margin-right: 0.4rem; 26 | } 27 | ` 28 | 29 | const ImageButtons = props => { 30 | const { options, filename } = props.image 31 | return ( 32 | 33 | { options ? 34 | { options.XDoG ? 'XDoG' : 'DoG' }, 35 | σ1 = { options.sigmaOne }, 36 | σ2 = { options.sigmaTwo } 37 | { !options.XDoG && , t = { options.threshold } }
38 | { options.XDoG && 39 | p = { options.sharpen }, φ = { options.phi }, ε = { options.epsilon } 40 | } 41 |
: { filename } } 42 |
43 | 46 | { props.image.sketched && 47 | 48 | 49 | Download 50 | 51 | } 52 |
53 |
54 | ) 55 | } 56 | 57 | export default connect(({ image }) => ({ image }))(ImageButtons) -------------------------------------------------------------------------------- /src/components/SettingsPane.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import SettingsForm from './SettingsForm' 4 | import SketchItButton from './SketchItButton' 5 | import { sketchify } from '../actions/imageActions' 6 | import cx from 'classnames' 7 | import styled from 'styled-components' 8 | import { formValueSelector } from 'redux-form' 9 | 10 | const SigmaWarning = styled.p` 11 | color: #e85600; 12 | font-size: .7rem; 13 | margin-top: .2rem; 14 | ` 15 | 16 | class SettingsPane extends Component { 17 | submitSettings = settings => { 18 | for(let key in settings) { 19 | if(!['gpuAccelerated', 'XDoG'].includes(key)) { 20 | settings[key] = parseFloat(settings[key]) 21 | } 22 | } 23 | 24 | this.props.dispatch(sketchify(settings)) 25 | } 26 | 27 | render() { 28 | const disabled = !this.props.image.url 29 | 30 | return ( 31 |
32 |
33 |
34 | Image Settings 35 |
36 |
37 |
38 | 39 | { this.props.sigmaRatio < 1 && ( 40 | Warning: setting σ1 > σ2 can yield odd-looking results. 41 | ) } 42 |
43 |
44 | 45 |
46 |
47 | ) 48 | } 49 | } 50 | 51 | const selector = formValueSelector('imageSettings') 52 | export default connect(state => { 53 | return { 54 | sigmaRatio: selector(state, 'sigmaTwo') / selector(state, 'sigmaOne'), 55 | image: state.image 56 | } 57 | })(SettingsPane) -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 27 | XDoG Sketch 28 | 29 | 30 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import '~spectre.css/dist/spectre.css'; 2 | @import '~spectre.css/dist/spectre-icons.css'; 3 | @import '~spectre.css/dist/spectre-exp.css'; 4 | 5 | .outer-container { 6 | overflow-x: scroll; 7 | } 8 | 9 | .container { 10 | margin-bottom: 2rem; 11 | min-width: 1000px; 12 | } 13 | 14 | .panel { 15 | transition: opacity 0.5s ease; 16 | } 17 | 18 | .panel.disabled { 19 | opacity: 0.25; 20 | cursor: not-allowed; 21 | } 22 | 23 | .panel.disabled * { 24 | pointer-events: none; 25 | } 26 | 27 | .panel .panel-body { 28 | overflow-y: visible; 29 | } 30 | 31 | .form-group { 32 | align-items: center; 33 | } 34 | 35 | .slider.tooltip:not([data-tooltip])::after { 36 | content: attr(data-formatted-value); 37 | width: 60px; 38 | text-align: center; 39 | } 40 | 41 | .slider.tooltip:not([data-tooltip])[disabled]::after { 42 | display: none; 43 | } 44 | 45 | .btn:disabled { 46 | pointer-events: auto; 47 | cursor: not-allowed; 48 | opacity: 1; 49 | border-color: #e7e9ed; 50 | color: #e7e9ed; 51 | } 52 | 53 | .btn:disabled:hover { 54 | background: transparent; 55 | } 56 | 57 | .form-label.disabled { 58 | color: #e7e9ed; 59 | } 60 | 61 | .comparison-slider .comparison-after::after { 62 | color: transparent; 63 | } 64 | 65 | .comparison-slider:hover .comparison-after::after { 66 | color: #fff; 67 | } 68 | 69 | .col-ml-auto { 70 | width: calc(50% - 1rem); 71 | } 72 | 73 | label { 74 | cursor: pointer; 75 | } 76 | 77 | .disabled .slider::-webkit-slider-thumb, .disabled .form-switch input:checked + .form-icon { 78 | background: #e7e9ed; 79 | border-color: #e7e9ed; 80 | } 81 | 82 | .modal-body h5 { 83 | margin: 2rem 0 1rem; 84 | } 85 | 86 | .modal.modal-lg .modal-overlay { 87 | background: rgba(248, 249, 250, .75); 88 | } 89 | 90 | .modal.modal-lg .modal-container { 91 | box-shadow: 0px 2px 11px -5px; 92 | } 93 | 94 | .modal-overlay, .btn-clear { 95 | font-size: 0; 96 | } 97 | 98 | .btn.btn-clear::before { 99 | font-size: 16px; 100 | } 101 | 102 | body.modal-open { 103 | overflow-y: hidden; 104 | } 105 | 106 | .btn { 107 | transition: none; 108 | } -------------------------------------------------------------------------------- /src/actions/imageActions.js: -------------------------------------------------------------------------------- 1 | import getPixels from 'get-pixels' 2 | import { DoGFilter, XDoGFilter, convertToGrayscale } from '../xdog' 3 | 4 | export const UPDATE_IMAGE_URL = 'UPDATE_IMAGE_URL' 5 | export const UPDATE_SOURCE_PIXELS = 'UPDATE_SOURCE_PIXELS' 6 | export const LOAD_NEW_IMAGE_PENDING = 'LOAD_NEW_IMAGE_PENDING' 7 | export const CLEAR_IMAGE = 'CLEAR_IMAGE' 8 | export const RERENDERING = 'RERENDERING' 9 | 10 | function updateImageUrl(payload, newImage) { 11 | if(newImage) payload.originalUrl = payload.url 12 | 13 | return { 14 | type: UPDATE_IMAGE_URL, 15 | payload 16 | } 17 | } 18 | 19 | function updateSourcePixels(payload) { 20 | return { 21 | type: UPDATE_SOURCE_PIXELS, 22 | payload 23 | } 24 | } 25 | 26 | function rerendering() { 27 | return { 28 | type: RERENDERING 29 | } 30 | } 31 | 32 | export function clearImage() { 33 | return { 34 | type: CLEAR_IMAGE 35 | } 36 | } 37 | 38 | function loadNewImagePending() { 39 | return { 40 | type: LOAD_NEW_IMAGE_PENDING 41 | } 42 | } 43 | 44 | export function loadNewImage(url, filename = '') { 45 | return dispatch => { 46 | dispatch(loadNewImagePending()) 47 | getPixels(url, (err, colorPixels) => { 48 | const [originalWidth, originalHeight, ...rest] = colorPixels.shape // eslint-disable-line no-unused-vars 49 | let [width, height] = [originalWidth, originalHeight] 50 | const scaleFactor = Math.min(470 / width, 600 / height) 51 | 52 | if(scaleFactor < 1) { 53 | width = originalWidth * scaleFactor 54 | height = originalHeight * scaleFactor 55 | } 56 | 57 | dispatch(updateImageUrl({ url, width, height, originalWidth, originalHeight, filename }, true)) 58 | 59 | convertToGrayscale(colorPixels).then(pixels => { 60 | dispatch(updateSourcePixels({ pixels })) 61 | }) 62 | }) 63 | } 64 | } 65 | 66 | export function sketchify(options) { 67 | return (dispatch, getState) => { 68 | dispatch(rerendering()) 69 | const { pixels, originalWidth, originalHeight } = getState().image 70 | const filterFn = options.XDoG ? XDoGFilter : DoGFilter 71 | 72 | filterFn(pixels, options, [originalWidth, originalHeight]).then(url => { 73 | dispatch(updateImageUrl({ url, sketched: true, options })) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/PresetImages.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import styled, { css } from 'styled-components' 4 | import bugatti from '../images/bugatti.jpg' 5 | import lenna from '../images/lenna.jpg' 6 | import baboon from '../images/baboon.jpg' 7 | import headscarf from '../images/headscarf.jpg' 8 | import peppers from '../images/peppers.jpg' 9 | import { loadNewImage } from '../actions/imageActions' 10 | 11 | const FULL_SIZE = { 12 | peppers: 'https://i.imgur.com/3rRblfO.jpg', 13 | headscarf: 'https://i.imgur.com/eB8UNho.jpg', 14 | baboon: 'https://i.imgur.com/332GZbX.jpg', 15 | lenna: 'https://i.imgur.com/tWSRzVw.jpg', 16 | bugatti: 'https://i.imgur.com/CjRP6ZI.jpg' 17 | } 18 | 19 | const Thumb = styled.img` 20 | max-height: 2rem; 21 | border-radius: 4px; 22 | margin: 0.2rem; 23 | opacity: 0.5; 24 | cursor: pointer; 25 | transition: opacity 0.3s ease; 26 | &:hover { 27 | opacity: 1; 28 | } 29 | ${ props => props.last && css` 30 | margin-right: 0; 31 | `} 32 | ` 33 | 34 | const Presets = styled.div` 35 | margin-top: 0.5rem; 36 | text-align: left; 37 | display: flex; 38 | justify-content: space-between; 39 | width: 100%; 40 | align-items: center; 41 | ` 42 | 43 | const Prompt = styled.p` 44 | margin-bottom: 0.4rem; 45 | font-size: 14px; 46 | ` 47 | 48 | const PresetImages = props => { 49 | const selectPreset = (url, filename) => { 50 | props.dispatch(loadNewImage(url, filename)) 51 | } 52 | 53 | return ( 54 | 55 | Or choose a preset... 56 |
57 | selectPreset(FULL_SIZE.peppers, 'peppers.jpg') } /> 58 | selectPreset(FULL_SIZE.headscarf, 'headscarf.jpg') } /> 59 | selectPreset(FULL_SIZE.lenna, 'lenna.jpg') } /> 60 | selectPreset(FULL_SIZE.baboon, 'baboon.jpg') } /> 61 | selectPreset(FULL_SIZE.bugatti, 'bugatti.jpg') } last /> 62 |
63 |
64 | ) 65 | } 66 | 67 | export default connect()(PresetImages) -------------------------------------------------------------------------------- /src/components/AboutModal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import cx from 'classnames' 4 | import { closeModal } from '../actions/aboutActions' 5 | 6 | class AboutModal extends Component { 7 | componentWillReceiveProps(nextProps) { 8 | document.body.classList.toggle('modal-open', nextProps.visible) 9 | } 10 | 11 | componentWillUnmount() { 12 | document.body.classList.remove('modal-open') 13 | } 14 | 15 | close = () => { 16 | this.props.dispatch(closeModal()) 17 | } 18 | 19 | render() { 20 | return ( 21 |
22 | Close 23 |
24 |
25 | Close 26 |
About
27 |
28 |
29 |
30 |

This is a recreation of the XDoG image stylization technique as described in the Winnemoller et. al's papers XDoG: Advanced Image Stylization with eXtended Difference-of-Gaussians (DOI: 10.1145/2024676.2024700) and XDoG: An eXtended difference-of-Gaussians compendium including advanced image stylization (DOI: 10.1016/j.cag.2012.03.004). The app uses Google's deeplearn.js library to perform fast, GPU-accelerated image processing in the browser.

31 | 32 |
Parameter guide
33 | 34 |

Sigma 1 and Sigma 2 control the strength of the two gaussian functions whose difference is used for edge detection. A lower Sigma 1 will create finer details (mimicking a detailed sketch), while a higher Sigma 1 will yield less detail. Where Sigma 2 is much higher than Sigma 1, the lines will be thicker and vica-versa. Sigma 2 should generally always be greater than Sigma 1.

35 | 36 |

Threshold defines the luminance threshold which used to binarize the image (convert to black & white) when you're not using XDoG mode. A lower threshold will mean more pixels become white, yielding a lighter image with thinner lines, and vica-versa. The threshold is quite sensitive, so images can quickly collapse to becoming white/very light or black/very dark.

37 | 38 |

Sharpen (p) controls the strength of the sharpening that's applied when using XDoG mode. Sharpening with a large p exaggerates both the black and white edges present in the result.

39 | 40 |

Phi (φ) controls the steepness of the soft thresholding that's applied when using XDoG mode. A larger phi will lead to a sharper black/white transitions in the image.

41 | 42 |

Epsilon (ε) controls the level above which the adjusted luminance values will become white. A higher epsilon will yield a darker image with greater regions of blackness, and vica-versa. A low epsilon more closely emulates the behaviour of DoG mode.

43 | 44 |
Author
45 | 46 |

© 2017 Alex Peattie / alexpeattie.com / @alexpeattie

47 |
48 |
49 |
50 |
51 | ) 52 | } 53 | } 54 | export default connect(({ about }) => ({ visible: about.visible }))(AboutModal) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XDoG Sketch 2 | 3 |

4 | 5 |

6 | 7 | Fast artistic rendering of photos in the browser with XDoG, React 16 & [deeplearn.js](https://deeplearnjs.org). The app recreates XDoG image stylization technique as described in the Winnemoller et. al's papers *XDoG: Advanced Image Stylization with eXtended Difference-of-Gaussians* (DOI: [10.1145/2024676.2024700](https://doi.org/10.1145/2024676.2024700)) and *XDoG: An eXtended difference-of-Gaussians compendium including advanced image stylization* (DOI: [10.1016/j.cag.2012.03.004](https://doi.org/10.1016/j.cag.2012.03.004)). The [deeplearn.js](https://deeplearnjs.org/) library is used to perform fast, GPU-accelerated image processing in the browser. 8 | 9 | ## Usage/Installation 10 | 11 | By far the easiest way to run & experiment with the app is to access the live version at: 12 | 13 | https://xdog.alexpeattie.com 14 | 15 | Alternatively, the app can be built and run locally; this requires a recent version of Node (>= 8). Dependencies can be installed with Yarn or NPM, then run the app with `npm run start`: 16 | 17 | ```bash 18 | yarn install 19 | # or 20 | npm install 21 | 22 | npm run start 23 | ``` 24 | 25 | The server will be accessible at http://localhost:3067/ by default, this can be customized by modifying the `PORT` variable in `package.json`. 26 | 27 | #### Building the app 28 | 29 | Alternatively, you can compile the app, then run it with a static server. Run: 30 | 31 | ``` 32 | npm run build 33 | ``` 34 | 35 | When the build is completed, all the compiled files will be in the `build/` directory, and can be served by any static file server. One popular option is [serve](https://github.com/zeit/serve): 36 | 37 | ``` 38 | npm install -g serve 39 | serve -s build 40 | ``` 41 | 42 | ## Dependencies 43 | 44 | This project was greatly helped by the following 3rd-party libraries: 45 | 46 | - [basename](https://github.com/architectcodes/basename.js) - for filename processing 47 | - [classnames](https://github.com/JedWatson/classnames) - for conditionally toggling CSS classes 48 | - [deeplearn.js](https://github.com/PAIR-code/deeplearnjs) - for fast, GPU-accelerated computation 49 | - [detect-browser](https://github.com/DamonOehlman/detect-browser) - to show warnings for outdated browsers 50 | - [gaussian-convolution-kernel](https://github.com/sidorares/gaussian-convolution-kernel) - to create a Gaussian kernel for a given window size and sigma 51 | - [get-pixels](https://github.com/scijs/get-pixels) - to convert pixels in an image to an NDArray 52 | - [ndarray](https://github.com/scijs/ndarray) - an efficient data structure for storing numerical data (image pixels in this case) 53 | - [Numeral.js](https://github.com/adamwdraper/Numeral-js) - for number formatting for the parameter sliders 54 | - [promise-file-reader](https://github.com/jahredhope/promise-file-reader) - for reading uploaded files 55 | - [React 16](https://github.com/facebook/react) - to power the UI 56 | - [react-dropzone](https://github.com/react-dropzone/react-dropzone) - to facilitate drag & drop uploads 57 | - [Redux](https://github.com/reactjs/redux), [redux-thunk](https://github.com/gaearon/redux-thunk) & [redux-form](https://github.com/erikras/redux-form) - for front-end state management 58 | - [save-pixels](https://github.com/scijs/save-pixels) - to write the resulting pixel NDArray to an image stream 59 | - [Spectre.css](https://github.com/picturepan2/spectre) - a CSS library used to style the app 60 | - [stream-to-promise](https://github.com/bendrucker/stream-to-promise) - to write the image stream generated by save-pixels to a buffer 61 | - [styled-components](https://github.com/styled-components/styled-components) - to ease the styling of React components 62 | 63 | ## License 64 | 65 | Nitlink is released under the MIT license. (See [LICENSE.md](./LICENSE.md)) 66 | 67 | ## Author 68 | 69 | Alex Peattie / [alexpeattie.com](https://alexpeattie.com/) / [@alexpeattie](https://twitter.com/alexpeattie) -------------------------------------------------------------------------------- /src/xdog.js: -------------------------------------------------------------------------------- 1 | import { Array3D, Array4D, NDArrayMathCPU, NDArrayMathGPU, Scalar } from 'deeplearn' 2 | import generateGuassianKernel from 'gaussian-convolution-kernel' 3 | import streamToPromise from 'stream-to-promise' 4 | import savePixels from 'save-pixels' 5 | import ndarray from 'ndarray' 6 | 7 | // Convenience method for initializing scalars 8 | const s = n => Scalar.new(n) 9 | 10 | export function convertToGrayscale(pixels) { 11 | const math = window.WebGLRenderingContext ? new NDArrayMathGPU() : new NDArrayMathCPU() 12 | const [width, height, channels] = pixels.shape 13 | 14 | const color = math.transpose(Array3D.new([height, width, channels], pixels.data), [1, 0, 2]).reshape(pixels.shape) 15 | 16 | const r = math.multiply(math.slice3D(color, [0, 0, 0], [width, height, 1]), s(0.299)) 17 | const g = math.multiply(math.slice3D(color, [0, 0, 1], [width, height, 1]), s(0.587)) 18 | const b = math.multiply(math.slice3D(color, [0, 0, 2], [width, height, 1]), s(0.114)) 19 | 20 | return math.add(r, math.add(g, b)).data() 21 | } 22 | 23 | function calculateKernelSize(sigma) { 24 | return Math.max(Math.round(sigma * 3) * 2 + 1, 3) 25 | } 26 | 27 | function guassianKernel(sigma) { 28 | const ks = calculateKernelSize(sigma) 29 | return Array4D.new([ks, ks, 1, 1], generateGuassianKernel(ks, sigma)) 30 | } 31 | 32 | function applyConvolution(math, original, kernel) { 33 | return math.conv2d(original, kernel, null, 1, 'same') 34 | } 35 | 36 | export function DoGFilter(pixels, options, shape) { 37 | const { sigmaOne, sigmaTwo, threshold, gpuAccelerated } = options 38 | const math = gpuAccelerated ? (new NDArrayMathGPU()) : (new NDArrayMathCPU()) 39 | 40 | return new Promise((resolve, reject) => { 41 | const original = Array3D.new([...shape, 1], pixels) 42 | 43 | const [kernelOne, kernelTwo] = [guassianKernel(sigmaOne), guassianKernel(sigmaTwo)] 44 | const [imgA, imgB] = [applyConvolution(math, original, kernelOne), applyConvolution(math, original, kernelTwo)] 45 | 46 | const diff = math.subtract(imgA, imgB) 47 | const diffPositive = math.subtract(diff, math.min(diff)) 48 | const relativeDiff = math.divide(diffPositive, math.max(diffPositive)) 49 | 50 | const result = math.multiply(math.step(math.subtract(relativeDiff, s(threshold))), s(255)) 51 | 52 | result.data().then(sketchPixels => { 53 | const pixelArray = ndarray(sketchPixels, shape) 54 | streamToPromise(savePixels(pixelArray, 'png')).then(buffer => { 55 | const image = 'data:image/png;base64,' + buffer.toString('base64') 56 | resolve(image) 57 | }) 58 | }) 59 | }) 60 | } 61 | 62 | function softThreshold(math, pixels, phi, epsilon) { 63 | return math.tanh(math.multiply(s(phi), math.subtract(pixels, s(epsilon)))) 64 | } 65 | 66 | export function XDoGFilter(pixels, options, shape) { 67 | const { sigmaOne, sigmaTwo, sharpen, epsilon, phi, gpuAccelerated } = options 68 | const math = gpuAccelerated ? (new NDArrayMathGPU()) : (new NDArrayMathCPU()) 69 | 70 | return new Promise((resolve, reject) => { 71 | const original = Array3D.new([...shape, 1], pixels) 72 | const rescaled = math.divide(original, s(255)) 73 | 74 | const [kernelOne, kernelTwo] = [guassianKernel(sigmaOne), guassianKernel(sigmaTwo)] 75 | const [imgA, imgB] = [applyConvolution(math, rescaled, kernelOne), applyConvolution(math, rescaled, kernelTwo)] 76 | 77 | const scaledDiff = math.subtract(math.multiply(s(sharpen + 1), imgA), math.multiply(s(sharpen), imgB)) 78 | const sharpened = math.multiply(math.multiply(rescaled, scaledDiff), s(255)) 79 | const mask = math.step(math.subtract(math.multiply(rescaled, scaledDiff), s(epsilon))) 80 | const inverseMask = math.add(math.multiply(mask, s(-1)), s(1)) 81 | 82 | const softThresholded = math.add(s(1), softThreshold(math, sharpened, phi, epsilon)) 83 | const result = math.multiply(math.add(mask, math.multiply(inverseMask, softThresholded)), s(255)) 84 | const resultScaled = math.multiply(math.divide(result, math.max(result)), s(255)) 85 | 86 | resultScaled.data().then(sketchPixels => { 87 | const pixelArray = ndarray(sketchPixels, shape) 88 | streamToPromise(savePixels(pixelArray, 'png')).then(buffer => { 89 | const image = 'data:image/png;base64,' + buffer.toString('base64') 90 | resolve(image) 91 | }) 92 | }) 93 | }) 94 | } --------------------------------------------------------------------------------