diff options
Diffstat (limited to 'client')
| -rw-r--r-- | client/app.js | 139 | ||||
| -rw-r--r-- | client/index.js | 8 | ||||
| -rw-r--r-- | client/lib/upload.helpers.js | 156 | ||||
| -rw-r--r-- | client/lib/uploadImage.component.js | 46 | ||||
| -rw-r--r-- | client/util.js | 55 |
5 files changed, 404 insertions, 0 deletions
diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..b1c5eb4 --- /dev/null +++ b/client/app.js @@ -0,0 +1,139 @@ +import React, { Component } from 'react' + +import UploadImage from './lib/uploadImage.component' +import { post } from './util' + +const initialState = { + image: null, + url: "", + res: null, + loading: false, +} + +export default class PhashApp extends Component { + state = { ...initialState } + + upload(blob) { + if (this.state.image) { + URL.revokeObjectURL(this.state.image) + } + const url = URL.createObjectURL(blob) + this.setState({ image: url, loading: true }) + + const fd = new FormData() + fd.append('q', blob) + post('/api/v1/match', fd) + .then(res => { + console.log(res) + this.setState({ res, loading: false }) + }) + .catch(err => { + console.log(err) + this.setState({ loading: false }) + }) + } + + submit() { + const { url } = this.state + if (!url || url.indexOf('http') !== 0) return + this.setState({ image: url, loading: true }) + + const fd = new FormData() + fd.append('url', url) + post('/api/v1/match', fd) + .then(res => { + console.log(res) + this.setState({ res, loading: false }) + }) + .catch(err => { + console.log(err) + this.setState({ loading: false }) + }) + } + + render() { + return ( + <div className='app'> + <h1>Search by Image</h1> + {this.renderQuery()} + {this.renderResults()} + </div> + ) + } + + renderQuery() { + const { image } = this.state + const style = {} + if (image) { + style.backgroundImage = 'url(' + image + ')' + style.backgroundSize = 'cover' + style.opacity = 1 + } + return ( + <div className='query'> + <label> + <span>Query image</span> + <UploadImage onUpload={this.upload.bind(this)} /> + </label> + <br /> + <label> + <span>Add a URL</span> + <input + type='text' + value={this.state.url} + onChange={e => this.setState({ url: e.target.value })} + onKeyDown={e => e.keyCode === 13 && this.submit()} + placeholder='https://' + /> + </label> + {image && <div style={style} />} + </div> + ) + } + renderResults() { + const { loading, res } = this.state + if (!res) { + return ( + <div className='results'> + </div> + ) + } + if (loading) { + return ( + <div className='results'> + <i>Loading...</i> + </div> + ) + } + + const { success, error, match, matches } = res + if (!success) { + return ( + <div className='results'> + <b>Error: {error.replace(/_/g, ' ')}</b> + </div> + ) + } + + if (!match) { + return ( + <div className='results'> + No match, image added to database + </div> + ) + } + + return ( + <div className='results'> + {matches.map(({ phash, score, sha256, url }) => ( + <div className='result'> + <img src={url} /><br /> + Match: {sha256}<br /> + Score: {score}<br /> + Phash: {phash.toString(16)} + </div> + ))} + </div> + ) + } +}
\ No newline at end of file diff --git a/client/index.js b/client/index.js new file mode 100644 index 0000000..1a71e55 --- /dev/null +++ b/client/index.js @@ -0,0 +1,8 @@ +import React from 'react' +import ReactDOM from 'react-dom' + +import App from './app' + +ReactDOM.render( + <App />, document.querySelector('#container') +) diff --git a/client/lib/upload.helpers.js b/client/lib/upload.helpers.js new file mode 100644 index 0000000..d0baab4 --- /dev/null +++ b/client/lib/upload.helpers.js @@ -0,0 +1,156 @@ +import ExifReader from 'exifreader' + +export const MAX_SIDE = 256 + +function base64ToUint8Array(string, start, finish) { + start = start || 0 + finish = finish || string.length + // atob that shit + const binary = atob(string) + const buffer = new Uint8Array(binary.length) + for (let i = start; i < finish; i++) { + buffer[i] = binary.charCodeAt(i) + } + return buffer +} + +function getOrientation(uri) { + // Split off the base64 data + const base64String = uri.split(',')[1] + // Read off first 128KB, which is all we need to + // get the EXIF data + const arr = base64ToUint8Array(base64String, 0, 2 ** 17) + try { + const tags = ExifReader.load(arr.buffer) + // console.log(tags) + return tags.Orientation + } catch (err) { + return 1 + } +} + +function applyRotation(canvas, ctx, deg) { + const radians = deg * (Math.PI / 180) + if (deg === 90) { + ctx.translate(canvas.width, 0) + } else if (deg === 180) { + ctx.translate(canvas.width, canvas.height) + } else if (deg === 270) { + ctx.translate(0, canvas.height) + } + ctx.rotate(radians) +} + +/** + * Mapping from EXIF orientation values to data + * regarding the rotation and mirroring necessary to + * render the canvas correctly + * Derived from: + * http://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/ + */ +const orientationToTransform = { + 1: { rotation: 0, mirror: false }, + 2: { rotation: 0, mirror: true }, + 3: { rotation: 180, mirror: false }, + 4: { rotation: 180, mirror: true }, + 5: { rotation: 90, mirror: true }, + 6: { rotation: 90, mirror: false }, + 7: { rotation: 270, mirror: true }, + 8: { rotation: 270, mirror: false } +} + +function applyOrientationCorrection(canvas, ctx, uri) { + const orientation = getOrientation(uri) + // Only apply transform if there is some non-normal orientation + if (orientation && orientation !== 1) { + const transform = orientationToTransform[orientation] + const { rotation } = transform + const flipAspect = rotation === 90 || rotation === 270 + if (flipAspect) { + // Fancy schmancy swap algo + canvas.width = canvas.height + canvas.width + canvas.height = canvas.width - canvas.height + canvas.width -= canvas.height + } + if (rotation > 0) { + applyRotation(canvas, ctx, rotation) + } + } +} + +function getScale(width, height, viewportWidth, viewportHeight, fillViewport) { + function fitHorizontal() { + return viewportWidth / width + } + function fitVertical() { + return viewportHeight / height + } + fillViewport = !!fillViewport + const landscape = (width / height) > (viewportWidth / viewportHeight) + if (landscape) { + if (fillViewport) { + return fitVertical() + } + if (width > viewportWidth) { + return fitHorizontal() + } + } else { + if (fillViewport) { + return fitHorizontal() + } + if (height > viewportHeight) { + return fitVertical() + } + } + return 1 +} + +export function renderToCanvas(img, options) { + if (!img) return null + options = options || {} + + // Canvas max size for any side + const maxSide = MAX_SIDE + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + const initialScale = options.scale || 1 + // Scale to needed to constrain canvas to max size + let scale = getScale(img.naturalWidth * initialScale, img.naturalHeight * initialScale, maxSide, maxSide, true) + // console.log(scale) + // Still need to apply the user defined scale + scale *= initialScale + canvas.width = Math.round(img.naturalWidth * scale) + canvas.height = Math.round(img.naturalHeight * scale) + const { correctOrientation } = options + const jpeg = !!img.src.match(/data:image\/jpeg|\.jpeg$|\.jpg$/i) + const hasDataURI = !!img.src.match(/^data:/) + + ctx.save() + + // Can only correct orientation on JPEGs represented as dataURIs + // for the time being + if (correctOrientation && jpeg && hasDataURI) { + applyOrientationCorrection(canvas, ctx, img.src) + } + // Resize image if too large + if (scale !== 1) { + ctx.scale(scale, scale) + } + + ctx.drawImage(img, 0, 0) + ctx.restore() + + return canvas +} + +export function renderThumbnail(img) { + const resized = renderToCanvas(img, { correctOrientation: true }) + // const canvas = document.createElement('canvas') // document.querySelector('#user_photo_canvas') + // const ctx = canvas.getContext('2d') + // ctx.fillStyle = 'black' + // ctx.fillRect(0, 0, MAX_SIDE, MAX_SIDE) + // const xOffset = (MAX_SIDE - resized.width) / 2 + // const yOffset = (MAX_SIDE - resized.height) / 2 + // ctx.drawImage(resized, xOffset, yOffset, resized.width, resized.height) + return resized +} diff --git a/client/lib/uploadImage.component.js b/client/lib/uploadImage.component.js new file mode 100644 index 0000000..690c0dc --- /dev/null +++ b/client/lib/uploadImage.component.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react' + +import { renderThumbnail } from './upload.helpers' + +export default class UploadImageComponent extends Component { + upload(e) { + const files = e.dataTransfer ? e.dataTransfer.files : e.target.files + let i + let file + for (i = 0; i < files.length; i++) { + file = files[i] + if (file && file.type.match('image.*')) break + } + if (!file) return + const fr = new FileReader() + fr.onload = fileReaderEvent => { + fr.onload = null + const img = new Image() + img.onload = () => { + img.onload = null + this.resizeAndUpload(img) + } + img.src = fileReaderEvent.target.result + } + fr.readAsDataURL(files[0]) + } + + resizeAndUpload(img) { + const canvas = renderThumbnail(img) + canvas.toBlob(blob => { + this.props.onUpload(blob) + }, 'image/jpeg', 80) + } + + render() { + return ( + <input + type="file" + name="img" + accept="image/*" + onChange={this.upload.bind(this)} + required + /> + ) + } +} diff --git a/client/util.js b/client/util.js new file mode 100644 index 0000000..5b20c2a --- /dev/null +++ b/client/util.js @@ -0,0 +1,55 @@ +/* Mobile check */ + +export const isiPhone = !!((navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i))) +export const isiPad = !!(navigator.userAgent.match(/iPad/i)) +export const isAndroid = !!(navigator.userAgent.match(/Android/i)) +export const isMobile = isiPhone || isiPad || isAndroid +export const isDesktop = !isMobile +export const isFirefox = typeof InstallTrigger !== 'undefined' + +const htmlClassList = document.body.parentNode.classList +htmlClassList.add(isDesktop ? 'desktop' : 'mobile') +if (isFirefox) { + htmlClassList.add('firefox') +} + +/* AJAX */ + +export const get = (uri, data) => { + let headers = { + Accept: 'application/json, application/xml, text/play, text/html, *.*', + } + let opt = { + method: 'GET', + body: data, + headers, + // credentials: 'include', + } + // console.log(headers) + // headers['X-CSRFToken'] = csrftoken + return fetch(uri, opt).then(res => res.json()) +} + +export const post = (uri, data) => { + let headers + if (data instanceof FormData) { + headers = { + Accept: 'application/json, application/xml, text/play, text/html, *.*', + } + } else { + headers = { + Accept: 'application/json, application/xml, text/play, text/html, *.*', + 'Content-Type': 'application/json; charset=utf-8', + } + data = JSON.stringify(data) + } + let opt = { + method: 'POST', + body: data, + headers, + // credentials: 'include', + } + // console.log(headers) + // headers['X-CSRFToken'] = csrftoken + return fetch(uri, opt).then(res => res.json()) +} |
