summaryrefslogtreecommitdiff
path: root/client/components
diff options
context:
space:
mode:
Diffstat (limited to 'client/components')
-rw-r--r--client/components/index.js11
-rw-r--r--client/components/query.component.js103
-rw-r--r--client/components/results.component.js56
-rw-r--r--client/components/timing.component.js13
-rw-r--r--client/components/upload.helpers.js156
-rw-r--r--client/components/uploadImage.component.js46
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
+ />
+ )
+ }
+}