diff options
Diffstat (limited to 'client/components')
| -rw-r--r-- | client/components/index.js | 11 | ||||
| -rw-r--r-- | client/components/query.component.js | 103 | ||||
| -rw-r--r-- | client/components/results.component.js | 56 | ||||
| -rw-r--r-- | client/components/timing.component.js | 13 | ||||
| -rw-r--r-- | client/components/upload.helpers.js | 156 | ||||
| -rw-r--r-- | client/components/uploadImage.component.js | 46 |
6 files changed, 385 insertions, 0 deletions
diff --git a/client/components/index.js b/client/components/index.js new file mode 100644 index 0000000..8c3f07a --- /dev/null +++ b/client/components/index.js @@ -0,0 +1,11 @@ +import Query from './query.component' +import Results from './results.component' +import Timing from './timing.component' +import UploadImage from './uploadImage.component' + +export { + Query, + Results, + Timing, + UploadImage +}
\ No newline at end of file diff --git a/client/components/query.component.js b/client/components/query.component.js new file mode 100644 index 0000000..bd98ce8 --- /dev/null +++ b/client/components/query.component.js @@ -0,0 +1,103 @@ +import React, { Component } from 'react' +import { bindActionCreators } from 'redux' +import { connect } from 'react-redux' + +import * as actions from '../actions' + +import UploadImage from './uploadImage.component' +class Query extends Component { + state = { + image: null, + blob: null, + url: "", + threshold: 20, + } + + handleUpload(blob) { + const { image, threshold } = this.state + if (image) { + URL.revokeObjectURL(image) + } + const url = URL.createObjectURL(blob) + this.setState({ image: url, blob }) + this.props.actions.upload(blob, threshold) + } + + handleURL() { + const { url, threshold } = this.state + if (!url || url.indexOf('http') !== 0) return + this.setState({ image: url, blob: null }) + this.props.actions.submit(url, threshold) + } + + resubmit() { + const { image, blob } = this.state + if (blob) { + this.handleUpload(blob) + } else { + this.handleURL() + } + } + + render() { + const { url, image, threshold } = this.state + const style = {} + if (image) { + style.maxWidth = 200 + style.maxHeight = 200 + style.backgroundImage = 'url(' + image + ')' + style.backgroundSize = 'cover' + style.opacity = 1 + } + return ( + <div className='query'> + <label> + <span>Query image</span> + <UploadImage onUpload={this.handleUpload.bind(this)} /> + </label> + {/* + <label> + <span>Add a URL</span> + <input + type='text' + value={url} + onChange={e => this.setState({ url: e.target.value })} + onKeyDown={e => e.keyCode === 13 && this.handleURL()} + placeholder='https://' + /> + </label> + */} + <label> + <span>Threshold</span> + <input + type='number' + value={threshold} + min={0} + max={64} + step={1} + onChange={e => this.setState({ threshold: parseInt(e.target.value) }) } + /> + <input + type='range' + value={threshold} + min={0} + max={64} + step={1} + onChange={e => this.setState({ threshold: parseInt(e.target.value) }) } + /> + <button onClick={this.resubmit}>Update</button> + </label> + {image && <div className='image' style={style} />} + </div> + ) + } +} + +const mapStateToProps = state => ({ + ...state.api, +}) +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators({ ...actions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(Query) diff --git a/client/components/results.component.js b/client/components/results.component.js new file mode 100644 index 0000000..a6f3052 --- /dev/null +++ b/client/components/results.component.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' + +function Results({ loading, res }) { + if (!res) { + return ( + <div className='results'> + </div> + ) + } + if (loading) { + return ( + <div className='results'> + <i>Loading...</i> + </div> + ) + } + + const { success, error, match, results } = 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'> + {results.map(({ phash, score, sha256, url }) => ( + <div className='result' key={sha256}> + <div className='img'><img src={url} /></div> + <br /> + {score == 0 + ? <span className='score'><b>Exact match</b></span> + : <span className='score'>Score: {score}</span> + }<br /> + <span className='sha256'>{sha256}</span> + Phash: {phash.toString(16)} + </div> + ))} + </div> + ) +} + +const mapStateToProps = state => state.api + +export default connect(mapStateToProps)(Results) diff --git a/client/components/timing.component.js b/client/components/timing.component.js new file mode 100644 index 0000000..7473a51 --- /dev/null +++ b/client/components/timing.component.js @@ -0,0 +1,13 @@ +import React from 'react' +import { connect } from 'react-redux' + +function Timing({ timing }) { + if (timing) { + return <small>Query completed in {Math.round(timing * 1000)} ms</small> + } + return null +} + +const mapStateToProps = state => state.api + +export default connect(mapStateToProps)(Timing) diff --git a/client/components/upload.helpers.js b/client/components/upload.helpers.js new file mode 100644 index 0000000..d0baab4 --- /dev/null +++ b/client/components/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/components/uploadImage.component.js b/client/components/uploadImage.component.js new file mode 100644 index 0000000..690c0dc --- /dev/null +++ b/client/components/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 + /> + ) + } +} |
