diff --git a/bin/paintserver b/bin/paintserver --- a/bin/paintserver +++ b/bin/paintserver @@ -17,12 +17,14 @@ from lib.cycloneerr import PrettyErrorHa from light9.namespaces import RDF, L9, DEV + + class Solve(PrettyErrorHandler, cyclone.web.RequestHandler): def post(self): painting = json.loads(self.request.body) with self.settings.stats.solve.time(): img = self.settings.solver.draw(painting) - sample, sampleDist = self.settings.solver.bestMatch(img) + sample, sampleDist = self.settings.solver.bestMatch(img, device=DEV['aura2']) with self.settings.graph.currentState() as g: bestPath = g.value(sample, L9['imagePath']).replace(L9[''], '') #out = solver.solve(painting) @@ -39,6 +41,17 @@ class Solve(PrettyErrorHandler, cyclone. self.settings.solver = light9.paint.solve.Solver(self.settings.graph) self.settings.solver.loadSamples() +class BestMatches(PrettyErrorHandler, cyclone.web.RequestHandler): + def post(self): + body = json.loads(self.request.body) + painting = body['painting'] + devs = [URIRef(d) for d in body['devices']] + with self.settings.stats.solve.time(): + img = self.settings.solver.draw(painting) + outSettings = self.settings.solver.bestMatches(img, devs) + self.write(json.dumps({ + 'settings': outSettings.asList() + })) class App(object): def __init__(self, show, session): @@ -46,19 +59,29 @@ class App(object): self.session = session self.graph = SyncedGraph(networking.rdfdb.url, "paintServer") - self.graph.initiallySynced.addCallback(self.launch) + self.graph.initiallySynced.addCallback(self.launch).addErrback(log.error) self.stats = scales.collection('/', scales.PmfStat('solve'), ) def launch(self, *args): - self.solver = light9.paint.solve.Solver(self.graph, sessions=[L9['show/dance2017/capture/moving1/cap961804']]) + self.solver = light9.paint.solve.Solver(self.graph, sessions=[ + L9['show/dance2017/capture/aura1/cap1876596'], + L9['show/dance2017/capture/aura2/cap1876792'], + L9['show/dance2017/capture/aura3/cap1877057'], + L9['show/dance2017/capture/aura4/cap1877241'], + L9['show/dance2017/capture/aura5/cap1877406'], + L9['show/dance2017/capture/q1/cap1874255'], + L9['show/dance2017/capture/q2/cap1873665'], + L9['show/dance2017/capture/q3/cap1876223'], + ]) self.solver.loadSamples() self.cycloneApp = cyclone.web.Application(handlers=[ (r'/stats', StatsForCyclone), (r'/solve', Solve), + (r'/bestMatches', BestMatches), ], debug=True, graph=self.graph, diff --git a/light9/effect/settings.py b/light9/effect/settings.py --- a/light9/effect/settings.py +++ b/light9/effect/settings.py @@ -16,8 +16,11 @@ def parseHex(h): if h[0] != '#': raise ValueError(h) return [int(h[i:i+2], 16) for i in 1, 3, 5] +def parseHexNorm(h): + return [x / 255 for x in parseHex(h)] + def toHex(rgbFloat): - return '#%02x%02x%02x' % tuple(int(v * 255) for v in rgbFloat) + return '#%02x%02x%02x' % tuple(max(0, min(255, int(v * 255))) for v in rgbFloat) def getVal(graph, subj): lit = graph.value(subj, L9['value']) or graph.value(subj, L9['scaledValue']) @@ -83,6 +86,27 @@ class _Settings(object): out._compiled.setdefault(row[0], {})[row[1]] = row[2] out._delZeros() return out + + @classmethod + def fromBlend(cls, graph, others): + """others is a list of (weight, Settings) pairs""" + out = cls(graph, []) + for weight, s in others: + if not isinstance(s, cls): + raise TypeError(s) + for row in s.asList(): # could work straight from s._compiled + if row[0] is None: + raise TypeError('bad row %r' % (row,)) + dd = out._compiled.setdefault(row[0], {}) + + if isinstance(row[2], basestring): + prev = parseHexNorm(dd.get(row[1], '#000000')) + newVal = toHex(prev + weight * numpy.array(parseHexNorm(row[2]))) + else: + newVal = dd.get(row[1], 0) + weight * row[2] + dd[row[1]] = newVal + out._delZeros() + return out def _zeroForAttr(self, attr): if attr == L9['color']: @@ -117,7 +141,7 @@ class _Settings(object): words = [] def accum(): for dev, av in self._compiled.iteritems(): - for attr, val in av.iteritems(): + for attr, val in sorted(av.iteritems()): words.append('%s.%s=%s' % (dev.rsplit('/')[-1], attr.rsplit('/')[-1], val)) @@ -126,7 +150,7 @@ class _Settings(object): return accum() return '<%s %s>' % (self.__class__.__name__, ' '.join(words)) - + def getValue(self, dev, attr): return self._compiled.get(dev, {}).get(attr, self._zeroForAttr(attr)) @@ -150,7 +174,7 @@ class _Settings(object): for dev, attr in self._vectorKeys(deviceAttrFilter): v = self.getValue(dev, attr) if attr == L9['color']: - out.extend([x / 255 for x in parseHex(v)]) + out.extend(parseHexNorm(v)) else: out.append(v) return out diff --git a/light9/effect/settings_test.py b/light9/effect/settings_test.py --- a/light9/effect/settings_test.py +++ b/light9/effect/settings_test.py @@ -4,7 +4,8 @@ from light9.rdfdb.patch import Patch from light9.rdfdb.localsyncedgraph import LocalSyncedGraph from light9.namespaces import RDF, L9, DEV from light9.effect.settings import DeviceSettings - + + class TestDeviceSettings(unittest.TestCase): def setUp(self): self.graph = LocalSyncedGraph(files=['test/cam/lightConfig.n3', @@ -112,3 +113,37 @@ class TestDeviceSettings(unittest.TestCa ]) self.assertEqual(0.36055512754639896, s1.distanceTo(s2)) + +L1 = L9['light1'] +ZOOM = L9['zoom'] +class TestFromBlend(unittest.TestCase): + def setUp(self): + self.graph = LocalSyncedGraph(files=['test/cam/lightConfig.n3', + 'test/cam/bg.n3']) + def testSingle(self): + self.assertEqual( + DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]), + DeviceSettings.fromBlend(self.graph, [ + (1, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]))])) + + def testScale(self): + self.assertEqual( + DeviceSettings(self.graph, [(L1, ZOOM, 0.1)]), + DeviceSettings.fromBlend(self.graph, [ + (.2, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)]))])) + + def testMixFloats(self): + self.assertEqual( + DeviceSettings(self.graph, [(L1, ZOOM, 0.4)]), + DeviceSettings.fromBlend(self.graph, [ + (.2, DeviceSettings(self.graph, [(L1, ZOOM, 0.5)])), + (.3, DeviceSettings(self.graph, [(L1, ZOOM, 1.0)])), + ])) + + def testMixColors(self): + self.assertEqual( + DeviceSettings(self.graph, [(L1, ZOOM, '#503000')]), + DeviceSettings.fromBlend(self.graph, [ + (.25, DeviceSettings(self.graph, [(L1, ZOOM, '#800000')])), + (.5, DeviceSettings(self.graph, [(L1, ZOOM, '#606000')])), + ])) diff --git a/light9/paint/solve.py b/light9/paint/solve.py --- a/light9/paint/solve.py +++ b/light9/paint/solve.py @@ -67,18 +67,22 @@ class ImageDistAbs(object): class Solver(object): - def __init__(self, graph, sessions=None, imgSize=(100, 75)): + def __init__(self, graph, sessions=None, imgSize=(100, 53)): self.graph = graph self.sessions = sessions # URIs of capture sessions to load self.imgSize = imgSize self.samples = {} # uri: Image array (float 0-255) self.fromPath = {} # imagePath: image array + self.path = {} # sample: path self.blurredSamples = {} - self.sampleSettings = {} # (uri, path): DeviceSettings + self.sampleSettings = {} # sample: DeviceSettings + self.samplesForDevice = {} # dev : [(sample, img)] def loadSamples(self): """learn what lights do from images""" + log.info('loading...') + with self.graph.currentState() as g: for sess in self.sessions: for cap in g.objects(sess, L9['capture']): @@ -87,11 +91,19 @@ class Solver(object): def _loadSample(self, g, samp): pathUri = g.value(samp, L9['imagePath']) - self.samples[samp] = self.fromPath[pathUri] = loadNumpy(pathUri.replace(L9[''], '')).astype(float) - self.blurredSamples[samp] = self._blur(self.samples[samp]) + img = loadNumpy(pathUri.replace(L9[''], '')).astype(float) + settings = DeviceSettings.fromResource(self.graph, samp) + + self.samples[samp] = img + self.fromPath[pathUri] = img + self.blurredSamples[samp] = self._blur(img) - key = (samp, pathUri) - self.sampleSettings[key] = DeviceSettings.fromResource(self.graph, samp) + self.path[samp] = pathUri + assert samp not in self.sampleSettings + self.sampleSettings[samp] = settings + devs = settings.devices() + if len(devs) == 1: + self.samplesForDevice.setdefault(devs[0], []).append((samp, img)) def _blur(self, img): return scipy.ndimage.gaussian_filter(img, 10, 0, mode='nearest') @@ -106,7 +118,7 @@ class Solver(object): ctx.fill() ctx.set_line_cap(cairo.LINE_CAP_ROUND) - ctx.set_line_width(w / 5) # ? + ctx.set_line_width(w / 15) # ? for stroke in painting['strokes']: for pt in stroke['pts']: op = ctx.move_to if pt is stroke['pts'][0] else ctx.line_to @@ -119,22 +131,67 @@ class Solver(object): #surface.write_to_png('/tmp/surf.png') return numpyFromCairo(surface) - def bestMatch(self, img): + def bestMatch(self, img, device=None): """the one sample that best matches this image""" #img = self._blur(img) results = [] dist = ImageDist(img) - for uri, img2 in sorted(self.samples.items()): + if device is None: + items = self.samples.items() + else: + items = self.samplesForDevice[device] + for uri, img2 in sorted(items): if img.shape != img2.shape: + log.warn("mismatch %s %s", img.shape, img2.shape) continue results.append((dist.distanceTo(img2), uri, img2)) results.sort() topDist, topUri, topImg = results[0] + print 'tops2' + for row in results[:4]: + print '%.5f' % row[0], row[1][-20:], self.sampleSettings[row[1]] + #saveNumpy('/tmp/best_in.png', img) #saveNumpy('/tmp/best_out.png', topImg) #saveNumpy('/tmp/mult.png', topImg / 255 * img) return topUri, topDist + + def bestMatches(self, img, devices=None): + """settings for the given devices that point them each + at the input image""" + dist = ImageDist(img) + devSettings = [] + for dev in devices: + results = [] + for samp, img2 in self.samplesForDevice[dev]: + results.append((dist.distanceTo(img2), samp)) + results.sort() + + s = self.blendResults([(d, self.sampleSettings[samp]) + for d, samp in results[:8]]) + devSettings.append(s) + return DeviceSettings.fromList(self.graph, devSettings) + + def blendResults(self, results): + """list of (dist, settings)""" + + dists = [d for d, sets in results] + hi = max(dists) + lo = min(dists) + n = len(results) + remappedDists = [1 - (d - lo) / (hi - lo) * n / (n + 1) + for d in dists] + total = sum(remappedDists) + + #print 'blend' + #for o,n in zip(dists, remappedDists): + # print o,n, n / total + blend = DeviceSettings.fromBlend( + self.graph, + [(d / total, sets) for + d, (_, sets) in zip(remappedDists, results)]) + return blend def solve(self, painting): """ @@ -226,7 +283,8 @@ class Solver(object): for dev, devSettings in settings.byDevice(): requestedColor = devSettings.getValue(dev, L9['color']) candidatePics = [] # (distance, path, picColor) - for (sample, path), s in self.sampleSettings.items(): + for sample, s in self.sampleSettings.items(): + path = self.path[sample] otherDevSettings = s.ofDevice(dev) if not otherDevSettings: continue diff --git a/light9/web/paint/paint-elements.coffee b/light9/web/paint/paint-elements.coffee --- a/light9/web/paint/paint-elements.coffee +++ b/light9/web/paint/paint-elements.coffee @@ -1,3 +1,5 @@ +log = console.log + class Painting constructor: (@svg) -> @strokes = [] @@ -176,16 +178,54 @@ Polymer is: "light9-paint" properties: { painting: { type: Object } + client: { type: Object } + graph: { type: Object } } ready: () -> # couldn't make it work to bind to painting's notifyPath events @$.canvas.addEventListener('paintingChanged', @paintingChanged.bind(@)) @$.solve.addEventListener('response', @onSolve.bind(@)) + + @clientSendThrottled = _.throttle(@client.send.bind(@client), 60) + @bestMatchPending = false paintingChanged: (ev) -> + U = (x) => @graph.Uri(x) + @painting = ev.detail @$.solve.body = JSON.stringify(@painting.getDoc()) - @$.solve.generateRequest() + #@$.solve.generateRequest() + + @$.bestMatches.body = JSON.stringify({ + painting: @painting.getDoc(), + devices: [ + U('dev:aura1'), U('dev:aura2'), U('dev:aura3'), U('dev:aura4'), U('dev:aura5'), + U('dev:q1'), U('dev:q2'), U('dev:q3'), + ]}) + + send = => + @$.bestMatches.generateRequest().completes.then (r) => + @clientSendThrottled(r.response.settings) + if @bestMatchPending + @bestMatchPending = false + send() + + if @$.bestMatches.loading + @bestMatchPending = true + else + send() onSolve: (response) -> + U = (x) => @graph.Uri(x) + + sample = @$.solve.lastResponse.bestMatch.uri + settingsList = [] + for s in @graph.objects(sample, U(':setting')) + try + v = @graph.floatValue(s, U(':value')) + catch + v = @graph.stringValue(s, U(':scaledValue')) + row = [@graph.uriValue(s, U(':device')), @graph.uriValue(s, U(':deviceAttr')), v] + settingsList.push(row) + @client.send(settingsList) diff --git a/light9/web/paint/paint-elements.html b/light9/web/paint/paint-elements.html --- a/light9/web/paint/paint-elements.html +++ b/light9/web/paint/paint-elements.html @@ -6,6 +6,7 @@ + @@ -98,7 +99,11 @@ + + +
To collector:
+
diff --git a/light9/web/paint/paint-report-elements.html b/light9/web/paint/paint-report-elements.html --- a/light9/web/paint/paint-report-elements.html +++ b/light9/web/paint/paint-report-elements.html @@ -26,7 +26,7 @@
Single pic best match:
- +
Error: {{solution.bestMatch.dist}}
diff --git a/light9/web/rdfdb-synced-graph.html b/light9/web/rdfdb-synced-graph.html --- a/light9/web/rdfdb-synced-graph.html +++ b/light9/web/rdfdb-synced-graph.html @@ -25,6 +25,7 @@ ready: function() { this.graph = new SyncedGraph('/rdfdb/syncedGraph', { '': 'http://light9.bigasterisk.com/', + 'dev': 'http://light9.bigasterisk.com/device/', 'rdf': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 'rdfs': 'http://www.w3.org/2000/01/rdf-schema#', 'xsd': 'http://www.w3.org/2001/XMLSchema#',