diff --git a/echoserver.py b/echoserver.py index 04d07f0..879d323 100644 --- a/echoserver.py +++ b/echoserver.py @@ -4,6 +4,7 @@ import tornado.ioloop import os web_dir = os.path.join(os.path.split(__file__)[0], 'www') +output_dir = os.path.join(os.path.split(__file__)[0], 'output') # This is our WebSocketHandler - it handles the messages # from the tornado server @@ -27,6 +28,7 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler): application = tornado.web.Application([ (r"/ws", WebSocketHandler), + (r"/output/(.*)", tornado.web.StaticFileHandler, {"path": output_dir}), (r"/(.*)", tornado.web.StaticFileHandler, {"path": web_dir, "default_filename": 'index.html'}), ],debug=True) diff --git a/portrait_compositor.py b/portrait_compositor.py index 9fb8884..5b1ddae 100644 --- a/portrait_compositor.py +++ b/portrait_compositor.py @@ -13,6 +13,9 @@ from websocket import create_connection import logging import json +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger('portraits') + camera = picamera.PiCamera() camera.rotation = 180 camera.resolution = (1920, 1080) @@ -24,6 +27,8 @@ outputResolution = (1000, 1000) genders = ['male', 'female', 'unknown'] perspectives = ['side', 'front'] gender_perspectives = [g+"_"+p for p in perspectives for g in genders] +# we also want dedicated composites for the perspectives +gender_perspectives.extend(perspectives) curdir = os.path.dirname(os.path.abspath(__file__)) tmpimage = '/tmp/piimage.jpg' @@ -42,6 +47,8 @@ environment = { 'LD_PRELOAD': '/usr/lib/arm-linux-gnueabihf/libopencv_core.so.2.4', } +updateGoesOk = True; + def updateStats(type, name, count, image_filename): params = { 'type': type, @@ -49,35 +56,41 @@ def updateStats(type, name, count, image_filename): 'time': int(time.time()), 'case_count': int(count), } - try: - ws = create_connection("ws://localhost:8888/ws") - js = json.dumps({ - 'type': type, - 'name': name, - 'img_src': os.path.basename(image_filename), - 'case_count': int(count), - }) - ws.send(js) - except Exception as e: - raise + # try: + # ws = create_connection("ws://localhost:8888/ws") + # js = json.dumps({ + # 'type': type, + # 'name': name, + # 'img_src': os.path.basename(image_filename), + # 'case_count': int(count), + # }) + # ws.send(js) + # except Exception as e: + # raise url = 'https://artstats.rubenvandeven.com/composites/views.php' - if count % 10 == 0: - # only send every one in x image, so that the server never can - # retrace _exact_ faces by comparing the sent frames. - with open(image_filename) as fp: - print('send request including image') + try: + if count % 10 == 0: + # only send every one in x image, so that the server never can + # retrace _exact_ faces by comparing the sent frames. + with open(image_filename) as fp: + logger.info('send request including image') + r = requests.post( + url , + files={'image': fp}, + params=params + ) + else: + logger.info('send request') r = requests.post( - url , - files={'image': fp}, + url, params=params ) - else: - print('send request') - r = requests.post( - url, - params=params - ) + updateGoesOk = True + except Exception as e: + updateGoesOk = False + logger.critical("Error when updating statistics") + logger.exception(e) class CompositeImage: def __init__(self, name, resolution): @@ -102,7 +115,7 @@ class CompositeImage: self.count = i name = self.get_frame_filename(self.count) img_file = os.path.join(dir, name) - print("\trestore {}".format(img_file)) + logger.info("\trestore {}".format(img_file)) self.image = np.array(Image.open(img_file)).astype('float64') self.state_dirty = False @@ -112,12 +125,15 @@ class CompositeImage: def get_frame_filename(self, i): return "{}-{}x{}-{}.png".format(self.name, self.resolution[0], self.resolution[1], i) + def get_current_filename(self): + return self.get_frame_filename(self.count) def save_image(self, dir): if self.state_dirty is False: + # don't save if no changes are made since last save return - name = self.get_frame_filename(self.count) + name = self.get_current_filename() filename = os.path.join(dir, name) self.get_image().save(filename) @@ -140,6 +156,9 @@ class CompositeCollection: def get_pickle_filename(self): return os.path.join(self.target_dir, self.id + ".p") + def get_json_filename(self): + return os.path.join(self.target_dir, "composites.json") + def load(self): pickle_file_name = self.get_pickle_filename() # if os.path.exists(pickle_file_name): @@ -151,7 +170,7 @@ class CompositeCollection: composites[name] = CompositeImage(name, self.size) composites[name].restore( data['c'][name], self.target_dir) except Exception as e: - print("Create new composite", e) + logger.info("Create new composite", e) for name in self.names: composites[name] = CompositeImage(name, self.size) @@ -163,9 +182,31 @@ class CompositeCollection: data['c'][name] = self.composites[name].count with open( self.get_pickle_filename(), "wb" ) as fp: - print("Save", data) + logger.info("Save", data) pickle.dump( data, fp ) + self.save_json() + + def save_json(self): + """ + Save statistics as json + """ + data = {} + for name in self.composites: + data[name] = { + 'count': self.composites[name].count, + 'img': self.composites[name].get_current_filename() + } + + with open( self.get_json_filename(), "w" ) as fp: + logger.debug("Json to {}".format(self.get_json_filename())) + json.dump(data, fp) + + ws = create_connection("ws://localhost:8888/ws") + ws.send("update") + + + def save_img(self, name): self.get(name).save_image(self.target_dir) @@ -192,19 +233,22 @@ class CompositeCollection: for i in range(start, end): filename = os.path.join(self.target_dir, c.get_frame_filename(i)) if os.path.exists(filename): - print("Clean {}".format(filename)) + logger.info("Clean {}".format(filename)) os.unlink(filename) def append_face(row, image, composites): # degrees to distinguish side (as we will never be able to use 90 :-( ) - suffix = 'side' if abs(float(row['yaw'])) > 20 else 'front' - # print('yaw:', float(row['yaw'])) - name = "{}_{}".format(row['gender'], suffix) - if name not in composites.names: - return - composite = composites.get(name) + # Plus, we now have a wide angle lens. + suffix = 'side' if abs(float(row['yaw'])) > 12 else 'front' + names = [suffix, "{}_{}".format(row['gender'], suffix)] + + compositesToUse = [] + for name in names: + if name not in composites.names: + return + compositesToUse.append(composites.get(name)) # TODO: matrix transform the image, to skew the face into being a flat-ish surface # This might yield less blurry composites @@ -228,16 +272,17 @@ def append_face(row, image, composites): # PIL.Image handles cropping outside the canvas by filling with black/transparent x = face_x + dx y = face_y + dy - print('crop') + logger.debug('crop') i = image.crop((x,y, x + size_x, y + size_y)) if suffix == 'side' and float(row['yaw']) < 0: - print('\tflip') + logger.debug('\tflip') i = i.transpose(Image.FLIP_LEFT_RIGHT) - print('add') - composite.addFace(i) - print('added') + for composite in compositesToUse: + logger.debug('add') + composite.addFace(i) + logger.debug('added') composites = CompositeCollection(gender_perspectives, outputResolution, os.path.join(curdir, 'output')) @@ -250,7 +295,7 @@ while True: img = Image.open(tmpimage) os.unlink(tmpimage) with open(tmpimageResults) as csvfile: - print("open csv") + logger.debug("open csv") data = csv.DictReader(csvfile) faces = 0 for row in data: @@ -258,15 +303,14 @@ while True: # not a valid face continue faces += 1 - print("append face") + logger.info("append face") append_face(row, img, composites) if faces > 0: - print("save :-)") + logger.info("save :-)") for name in composites.names: - print("\tsave img '{}'".format(name)) + logger.info("\tsave img '{}'".format(name)) c = composites.save_img(name) # save pickle after images, so they can be restored composites.save() composites.clean() - # TODO: trigger output update diff --git a/www/composite_loader.js b/www/composite_loader.js new file mode 100644 index 0000000..8dda78e --- /dev/null +++ b/www/composite_loader.js @@ -0,0 +1,100 @@ +class Face { + constructor(id) { + this.id = id; + this.value = 0; + // this.name = name; + this.imgEls = []; + this.valueEls = []; + + let els = document.querySelectorAll(`[data-face='${this.id}']`); + for(let el of els) { + if(el.tagName == 'IMG') { + this.imgEls.push(el); + } else { + this.valueEls.push(el); + } + } + } + + addImgEl(el) { + this.imgEls.push(el) + } + + addValueEl(el) { + this.valueEls.push(el) + } + + update(imgUrl, value) { + for(let el of this.valueEls) { + el.textContent = new Intl.NumberFormat("nl").format(value); + } + for(let el of this.imgEls) { + el.src = imgUrl; + } + } +} + +class Bar { + constructor(el) { + this.el = el; + this.values = {}; + } + + setValue(id, value) { + this.values[id] = value; + this.update(); + } + + update() { + for(let id in this.values) { + let el = document.getElementById('graph--' + id); + if(!el) continue; + + let nrEls = el.getElementsByClassName('nr'); + for(let nrEl of nrEls) { + nrEl.textContent = new Intl.NumberFormat("nl").format(this.values[id]); + } + + el.style.flex = parseInt(this.values[id]); + } + } + +} + +var bar = new Bar(document.getElementById('graph')); + +var faces = { + 'front': new Face('front'), + 'side': new Face('side'), + 'male_front': new Face('male_front'), + 'female_front': new Face('female_front'), + 'unknown_front': new Face('unknown_front'), + 'male_side': new Face('male_side'), + 'female_side': new Face('female_side'), + 'unknown_side': new Face('unknown_side') +} + +var update = function() { + let req = new XMLHttpRequest(); + req.addEventListener("load", function(e){ + let data = JSON.parse(this.responseText); + console.log(data); + for(let name in data){ + faces[name].update('/output/'+data[name]['img'], data[name]['count']); + } + + bar.setValue('male', data['male_front']['count'] + data['male_side']['count']) + bar.setValue('female', data['female_front']['count'] + data['female_side']['count']) + bar.setValue('unknown', data['unknown_front']['count'] + data['unknown_side']['count']) + }); + req.open("GET", "/output/composites.json?v=" + Date.now()); + req.send(); +} + +var socket = new ReconnectingWebSocket("ws://"+window.location.hostname+":"+window.location.port+"/ws") +socket.addEventListener('message', function (event) { + //trigger update + update(); +}); + +update(); diff --git a/www/index.html b/www/index.html index df9d567..44a5a4e 100644 --- a/www/index.html +++ b/www/index.html @@ -9,291 +9,164 @@

Specimens of Discriminatory Composite Portraiture

Gender Differentiation*

+
- - - - - - - - - - - - - - - - - - 20 cases - 1.200cases - 120 cases - - 1.340 cases - 800 cases - M - F - ? - M - F - ? - - - +
+
+ +
female 12.320 cases
+
+
+ +
unknown 100 cases
+
+
+ +
male 120 cases
+
+
+
+
+ +
100 side faces
+
+
+ +
100 front faces
+
+
+
+
+ +
male 120 cases
+
+
+ +
unknown 100 cases
+
+
+ +
female 12.320 cases
+
+
+ + + + + + + + + + + + + + + +
-
-

Distinctive traits

+
+
+ +

composite subtraction

+

An examination of discriminatory traits in gender differentation.

- +
+ +
+
+ +
+
Male
+
+
+
+
+
+ +
+
+ +
+
Unknown
- +
+ +
+
+ +
+
Female
-
- -
- +
+
+
+
+
Female
+
Unknown
+
Male
-
+
0 cases
-
+
0 cases
-
+
0 cases
- +
* according to the 2018 edition of Affectiva's gender detection toolkit
Ruben van de Ven
+ + diff --git a/www/styles.css b/www/styles.css index 545bdec..aeabca0 100644 --- a/www/styles.css +++ b/www/styles.css @@ -6,6 +6,7 @@ html{ /* filter: invert(1); */ background: var(--secundary-color); + /* background-color: darkred; */ } body{ font-family: "CMU Serif"; @@ -34,7 +35,7 @@ html{ padding: 20px 0 20px 0; } section#gender{ - height: 44%; + height: 47%; border-bottom: solid 4px var(--primary-color); } @@ -53,7 +54,7 @@ html{ font-size: 40%; display: inline-block; position: relative; - top: -12px; + top: -27px; } .genders{ @@ -63,11 +64,6 @@ html{ #gender h2{ letter-spacing: 2px; } - - #ethnicity h2{ - /* letter-spacing: 2px; */ - } - #affdex{ position: absolute; top: calc(100% - 18px); @@ -85,15 +81,68 @@ html{ text-align: right; } + .genders { + display: flex; + /* flex-direction: row; */ + justify-content: space-between; + /* vertical align: */ + align-items: center; + margin: 30px; + + } + + .gender-wrap{ + position: relative; + } + + .genders > div{ + font-size: 20pt; + width: 195px; + } + + .genders .composite img{ + width: 100%; + /* height: 180px; */ + } + + /* .genders #front--composite{ + margin-top: 10px; + } */ + + .genders #front--composite{ + margin-top: 30px; + } + + .genders .type{ + text-transform: uppercase;; + display:block; + margin-top: -45px; + } + #graph{ display: flex; flex-direction: row; margin: 0 20px; padding-top: 50px; clear:both; + font-size: 22pt; } #graph > div{ - width: 33%; + flex: 1; + min-width: 5%; + overflow: hidden; + } + + #lines--side, #lines--front{ + position: absolute; + top: 100px; + left: 200px; + z-index: -1; + } + #lines--side{ + top: 120px; + left: 580px; + transform: scale(-1); } @@ -127,28 +176,73 @@ html{ #graph .cases{ text-align: center; + text-wrap: wrap; + height: 1.2em;; } #differences{ border-bottom: solid 4px var(--primary-color); - padding-bottom: 50px; + padding-bottom: 10px; + display:flex; + height: 497px; } #differences::after{ content: ''; clear:both; display: block; } - #differences .gender { - width: 50%; - margin: 50px 0 0 0; - float:left; + font-size: 18pt; + } + #differences > div{ + flex: 1; + } + #differences .gender { + width: 100%; + margin: 40px 0 0 0; + /* float:left; */ text-align: center; + position: relative; + text-transform: uppercase; + } + #differences .gender + .gender{ + margin-top: 20px; + } + #differences .gender.explanation { + text-align: left; + text-transform: none; + padding-left: 85px; + padding-right: 50px; + box-sizing: border-box; + } + #differences .gender.explanation h3{ + margin: 60px 0 0 0; + padding:0; + } + #differences .gender.explanation p{ + margin: 0; + } + + #differences .gender.explanation + .gender{ + margin-top: 40px; + } + + #differences .gender .diff-layer{ + position: absolute; + top:0; + right:0; + left:0; + mix-blend-mode: difference; + } + + #differences .gender .original-layer img{ + /* black yields no difference for blend-mode */ + border-color: black; } #differences .gender img{ - width: 180px; - height: 180px; + width: 170px; + height: 170px; border: solid 2px var(--primary-color); } @@ -156,3 +250,57 @@ html{ border-bottom: none; margin-top: 40px; } + + #bar-wrap{ + position: relative; + } + + #legend{ + position: absolute; + border-top: solid 4px var(--primary-color); + border-right: solid 4px var(--primary-color); + border-bottom: solid 1px var(--secundary-color); + background-color: var(--secundary-color); + bottom: calc(100%); + left:0; + width: 46%; + padding: 20px 22px 0 22px; + font-size: 20pt; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + + } + + #legend div{ + display: inline-block; + text-transform: uppercase; + position: relative; + top: 15px; + font-size: 18pt; + } + #legend div::before{ + content: ''; + display: inline-block; + width: 1em; + height: 1em; + border: solid 2px var(--primary-color); + outline: 2px solid var(--secundary-color); + margin-right: 10px; + background-image:url('texture-cross.png'); + background-size: 12px; + filter:invert(1); + font-size: 15pt; + /* vertical-align: baseline; */ + position: relative; + top: 2px; + + } + #legend div:nth-child(2)::before{ + background-image:url('texture-diagonal.png'); + background-size: 12px; + } + #legend div:nth-child(3)::before{ + background-image:url('texture-ball.png'); + background-size: 12px; + }