From ca80875372d66d35ef7cea229d0550f4877370ac Mon Sep 17 00:00:00 2001 From: Ville Rantanen Date: Fri, 27 May 2022 11:21:49 +0300 Subject: [PATCH] initial --- Bakefile | 15 +++ README.md | 27 ++++++ code/Dockerfile | 7 ++ code/labeler.py | 162 +++++++++++++++++++++++++++++++++ code/requirements.txt | 2 + code/revprox.py | 32 +++++++ code/start.me | 6 ++ code/static/data | 1 + code/static/script.js | 2 + code/static/style.css | 98 ++++++++++++++++++++ code/templates/layout.html | 14 +++ code/templates/show_image.html | 53 +++++++++++ docker-compose.yml | 14 +++ example.env | 5 + 14 files changed, 438 insertions(+) create mode 100644 Bakefile create mode 100644 README.md create mode 100644 code/Dockerfile create mode 100644 code/labeler.py create mode 100644 code/requirements.txt create mode 100644 code/revprox.py create mode 100755 code/start.me create mode 120000 code/static/data create mode 100644 code/static/script.js create mode 100644 code/static/style.css create mode 100644 code/templates/layout.html create mode 100644 code/templates/show_image.html create mode 100644 docker-compose.yml create mode 100644 example.env diff --git a/Bakefile b/Bakefile new file mode 100644 index 0000000..c05f6ce --- /dev/null +++ b/Bakefile @@ -0,0 +1,15 @@ +# https://github.com/moonq/bake + +if [[ ! -e .env ]]; then + cp -v example.env .env +fi + + +up() { # Run the service + docker-compose up --build -t 0 --force-recreate -d + docker-compose logs -f -t --tail=1000 +} + +down() { # Stop the service + docker-compose down -t 0 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..49cd87f --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +Simle Image Labeler + +For trainig machine learning algorithms. + +Setup: + +- Copy `example.env` as `.env` +- Start the docker instance +- Write a data/config.json: + +``` +{ "title": "My Labeler", +"labels": [ + { + "type": "checkbox", "name": "my_check", "value": "off"}, + {"type": "text", "name": "my_text", "value": "1.0"}, + { + "type": "range", + "name": "my_slider", + "value": "75", + "min": "0", + "max": "100", + } + ] +} + +``` diff --git a/code/Dockerfile b/code/Dockerfile new file mode 100644 index 0000000..2f47ba2 --- /dev/null +++ b/code/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3 +COPY requirements.txt /requirements.txt +RUN pip3 install --upgrade pip +RUN pip3 install -r /requirements.txt +COPY . /code/ +WORKDIR /code +CMD ./start.me diff --git a/code/labeler.py b/code/labeler.py new file mode 100644 index 0000000..7b1d8c1 --- /dev/null +++ b/code/labeler.py @@ -0,0 +1,162 @@ +import time +import datetime +import os +import re +import json +import logging +from flask import ( + Flask, + request, + session, + g, + redirect, + url_for, + abort, + render_template, + flash, +) +from revprox import ReverseProxied + +# configuration +IMAGEDIR = "/data/images/" +LABELDIR = "/data/labels/" +CONFIG_FILE = "/data/config.json" +DEBUG = False +SECRET_KEY = os.environ.get("SECRETKEY", "development key") +for d in (IMAGEDIR, LABELDIR): + try: + os.makedirs(d) + except FileExistsError: + pass + +app = Flask(__name__) +app.config.from_object(__name__) +app.wsgi_app = ReverseProxied(app.wsgi_app) + + +@app.before_request +def before_request_func(): + try: + with open(app.config["CONFIG_FILE"], "rt") as fp: + g.config = json.load(fp) + g.labels = g.config["labels"] + except Exception as e: + logging.warning(e) + logging.warning("config.json could not be read. using defaults.") + g.labels = [ + {"type": "checkbox", "name": "my_check", "default": "off"}, + {"type": "text", "name": "my_text", "default": "1.0"}, + { + "type": "range", + "name": "my_slider", + "default": "75", + "min": "0", + "max": "100", + }, + ] + g.config = {"title": "Labeler", "labels": g.labels} + + for label in g.labels: + label['value'] = label['default'] + +def natural_key(string_): + """See http://www.codinghorror.com/blog/archives/001018.html""" + return [int(s) if s.isdigit() else s for s in re.split(r"(\d+)", string_)] + + +def get_metadata_path(image_path): + base, ext = os.path.splitext(os.path.basename(image_path)) + jsonpath = os.path.join(app.config["LABELDIR"], base + ".json") + return jsonpath + + +def get_current_image(images): + for i, f in enumerate(images): + jsonpath = get_metadata_path(f) + if not os.path.exists(jsonpath): + return i + return i + + +def get_image_list(): + return sorted( + [ + os.path.join(app.config["IMAGEDIR"], x) + for x in os.listdir(app.config["IMAGEDIR"]) + if not x.endswith("json") + ], + key=natural_key, + ) + + +def get_metadata(imagepath): + try: + with open(get_metadata_path(imagepath), "rt") as fp: + values = json.load(fp) + metadata = [x.copy() for x in g.labels.copy()] + for label in metadata: + if label["name"] in values: + label["value"] = values[label["name"]] + else: + label["value"] = label["default"] + + return metadata + except FileNotFoundError: + # Return defaults + return g.labels + except Exception as e: + logging.error(e) + # Return defaults + return g.labels + + +def set_metadata(values, imagepath): + + metadata = [x.copy() for x in g.labels.copy()] + for label in metadata: + if not label["name"] in values: + values[label["name"]] = label["default"] + + # logging.warning((path, values)) + if not os.path.exists( + os.path.join(app.config["IMAGEDIR"], os.path.basename(imagepath)) + ): + return + with open(get_metadata_path(imagepath), "wt") as fp: + return json.dump(values, fp, indent=2, sort_keys=True) + + +@app.route("/", methods=["GET", "POST"]) +@app.route("/image:", methods=["GET", "POST"]) +def show_image(id=None): + + if request.method == "POST": + # parse form + image_name = request.form["image_name"] + metadata = {} + for key in request.form: + if key.startswith("label_"): + dictkey = key[6:] + metadata[dictkey] = str(request.form[key]) + set_metadata(metadata, image_name) + + images = get_image_list() + if id == None: + id = get_current_image(images) + else: + id = max(0, min(len(images) - 1, int(id))) + labels = get_metadata(images[id]) + id_minus = max(0, min(len(images) - 1, int(id - 1))) + id_plus = max(0, min(len(images) - 1, int(id + 1))) + return render_template( + "show_image.html", + id=id, + count=len(images), + image=images[id], + image_name=os.path.basename(images[id]), + image_plus=images[id_plus], + labels=labels, + id_minus=id_minus, + id_plus=id_plus, + title=g.config.get("title", "Labeler"), + ) \ No newline at end of file diff --git a/code/requirements.txt b/code/requirements.txt new file mode 100644 index 0000000..e4a286c --- /dev/null +++ b/code/requirements.txt @@ -0,0 +1,2 @@ +flask +gunicorn diff --git a/code/revprox.py b/code/revprox.py new file mode 100644 index 0000000..9649e61 --- /dev/null +++ b/code/revprox.py @@ -0,0 +1,32 @@ +class ReverseProxied(object): + '''Wrap the application in this middleware and configure the + front-end server to add these headers, to let you quietly bind + this to a URL other than / and to an HTTP scheme that is + different than what is used locally. + + In nginx: + location /myprefix { + proxy_pass http://192.168.0.1:5001; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Script-Name /myprefix; + } + + :param app: the WSGI application + ''' + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + script_name = environ.get('HTTP_X_SCRIPT_NAME', '') + if script_name: + environ['SCRIPT_NAME'] = script_name + path_info = environ['PATH_INFO'] + if path_info.startswith(script_name): + environ['PATH_INFO'] = path_info[len(script_name):] + + scheme = environ.get('HTTP_X_SCHEME', '') + if scheme: + environ['wsgi.url_scheme'] = scheme + return self.app(environ, start_response) diff --git a/code/start.me b/code/start.me new file mode 100755 index 0000000..5ee9e11 --- /dev/null +++ b/code/start.me @@ -0,0 +1,6 @@ +#!/bin/bash + + +useradd --no-create-home -u $UID user + +exec runuser user -m -c 'gunicorn -b 0.0.0.0:8080 -w 4 labeler:app' diff --git a/code/static/data b/code/static/data new file mode 120000 index 0000000..249cda9 --- /dev/null +++ b/code/static/data @@ -0,0 +1 @@ +/data \ No newline at end of file diff --git a/code/static/script.js b/code/static/script.js new file mode 100644 index 0000000..139597f --- /dev/null +++ b/code/static/script.js @@ -0,0 +1,2 @@ + + diff --git a/code/static/style.css b/code/static/style.css new file mode 100644 index 0000000..e933d0a --- /dev/null +++ b/code/static/style.css @@ -0,0 +1,98 @@ +body { font-family: sans-serif; background: #888; margin: 0px; + min-height: 100vh; width: 100vw; overflow-x: hidden; } +a, h1, h2 { color: #377ba8; } +h1, h2 { font-family: 'Georgia', serif; margin: 0; } +h1 { border-bottom: 2px solid #eee; } +h2 { font-size: 1.2em; } + +tr,td,tbody { margin: 0px; } + +.page { margin: 0em; + padding: 0em; background: #888; min-height: 100vh; width: 100vw;} +.entries { list-style: none; margin: 0; padding: 0; width:100%; min-height: 95vh; } + +.large { font-size: 3em; } +.right { text-align: right; } +.center { text-align: center; } + +#image { position: absolute; + left:0px; top:0px; + width: calc(100vw - 220px); /*height:95vh;*/ } +#img { max-width: calc(100vw - 220px); max-height:95vh; + width: auto; height: auto; + display: block; margin-left: auto; + margin-right: auto; } + +#img_title { overflow-x: hidden; + overflow-wrap: anywhere; +} + +#topright { position: absolute; + right:0px; top:0px; + /*width: 20vw;*/ + height:95vh; + font-size: large; } +.inputcontainer { + z-index:1; + background-color: #aaa; + padding: 5px; + margin-bottom: 3px; + border-radius: 5px; + width: 210px; +} + + +input, select { + width: 180px; + font-size: large; + } + + +input[type="text"] { + +} +input[type="submit"] { + +} +input[type="checkbox"] { + width: 3em; + height: 1.5em; +} +label { display: block; } +output { display: block; font-weight: bold; } + +.button_next { + width: 2em; + height: 3em; + font-size: large; + margin-top: 1em; +} +.float_left { + float: left; +} +.float_right { + float:right; +} +.button_save { + height: 3em; + margin-top: 1em; + margin-left: 1em; + margin-right: 1em; + clear: both; +} +.button_continue { + height: 4em; + margin-top: 3em; + margin-left: auto; + margin-right: auto; + width: 6em; +} + +.preload { + position: absolute; + left: 0px; + bottom: 0px; + width: 1px; + height: 1px; + overflow: hidden; +} \ No newline at end of file diff --git a/code/templates/layout.html b/code/templates/layout.html new file mode 100644 index 0000000..d237cc1 --- /dev/null +++ b/code/templates/layout.html @@ -0,0 +1,14 @@ + + +{{ title }} + + + + + + +
+ + {% block body %}{% endblock %} +
+ diff --git a/code/templates/show_image.html b/code/templates/show_image.html new file mode 100644 index 0000000..778a7dc --- /dev/null +++ b/code/templates/show_image.html @@ -0,0 +1,53 @@ +{% extends "layout.html" %} +{% block body %} +
+
+ +
{{ image_name }} ({{id+1}}/{{count}})
+
+ +
+
+ + {% for label in labels %} +
+ +
+ {% if label.type == "checkbox" %} + + {% endif %} + {% if label.type == "text" %} + + {% endif %} + {% if label.type == "number" %} + + {% endif %} + {% if label.type == "range" %} + + {{label.value}} + {% endif %} + {% if label.type == "select" %} + + {% endif %} +
+
+ {% endfor %} +
+ +
+
+
+ + + +
+ +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b23c211 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '2' + + +services: + labeler: + build: + context: code + ports: + - "${EXPOSE}:8080" + volumes: + - "${DATAFOLDER}:/data/" + environment: + - UID=${UID} + restart: unless-stopped diff --git a/example.env b/example.env new file mode 100644 index 0000000..6fa517f --- /dev/null +++ b/example.env @@ -0,0 +1,5 @@ +EXPOSE=8088 +DATAFOLDER=./data +UID=1000 +SECRETKEY=K0gzwB+fYb1kfbsTzP5i +