initial
This commit is contained in:
15
Bakefile
Normal file
15
Bakefile
Normal file
@@ -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
|
||||
}
|
||||
27
README.md
Normal file
27
README.md
Normal file
@@ -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",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
7
code/Dockerfile
Normal file
7
code/Dockerfile
Normal file
@@ -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
|
||||
162
code/labeler.py
Normal file
162
code/labeler.py
Normal file
@@ -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:<int:id>", 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"),
|
||||
)
|
||||
2
code/requirements.txt
Normal file
2
code/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask
|
||||
gunicorn
|
||||
32
code/revprox.py
Normal file
32
code/revprox.py
Normal file
@@ -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)
|
||||
6
code/start.me
Executable file
6
code/start.me
Executable file
@@ -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'
|
||||
1
code/static/data
Symbolic link
1
code/static/data
Symbolic link
@@ -0,0 +1 @@
|
||||
/data
|
||||
2
code/static/script.js
Normal file
2
code/static/script.js
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
98
code/static/style.css
Normal file
98
code/static/style.css
Normal file
@@ -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;
|
||||
}
|
||||
14
code/templates/layout.html
Normal file
14
code/templates/layout.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<head>
|
||||
<title>{{ title }}</title>
|
||||
<link rel=stylesheet type=text/css href="{{ url_for('static', filename='style.css') }}">
|
||||
<meta name="viewport" content="width=440" />
|
||||
<script language="javascript" src="{{ url_for('static', filename='script.js') }}" ></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class=page>
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
53
code/templates/show_image.html
Normal file
53
code/templates/show_image.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="entries">
|
||||
<div id="image">
|
||||
<img id="img" src="{{ url_for('static', filename=image) }}" >
|
||||
<div class=center id="img_title" >{{ image_name }} ({{id+1}}/{{count}})</div>
|
||||
</div>
|
||||
|
||||
<div id="topright">
|
||||
<form action="{{ url_for('show_image', id = id_plus) }}" method=post class=add-entry>
|
||||
<input type=hidden value="{{ image_name }}" name=image_name>
|
||||
{% for label in labels %}
|
||||
<div class=inputcontainer>
|
||||
<label>{{ label.name }}:</label>
|
||||
<div class=center>
|
||||
{% if label.type == "checkbox" %}
|
||||
<input class=center type="checkbox" name="label_{{ label.name }}" {% if label.value == "on" %}checked{% endif %}>
|
||||
{% endif %}
|
||||
{% if label.type == "text" %}
|
||||
<input type="text" name="label_{{ label.name }}" value="{{ label.value }}">
|
||||
{% endif %}
|
||||
{% if label.type == "number" %}
|
||||
<input type="number" step="any" name="label_{{ label.name }}" value="{{ label.value }}">
|
||||
{% endif %}
|
||||
{% if label.type == "range" %}
|
||||
<input type="range" name="label_{{ label.name }}" value="{{ label.value }}" min="{{ label.min }}" max="{{ label.max }}" oninput="this.nextElementSibling.value = this.value">
|
||||
<output>{{label.value}}</output>
|
||||
{% endif %}
|
||||
{% if label.type == "select" %}
|
||||
<select name="label_{{ label.name }}">
|
||||
{% for opt in label.options %}
|
||||
<option value="{{opt}}" {% if opt == label.value %}SELECTED{% endif %}>{{opt}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class=center>
|
||||
<input type=submit value="save" name=save class="button_save">
|
||||
</div>
|
||||
</form>
|
||||
<div class=center>
|
||||
<button class="button_next float_left" onclick="location.href='{{ url_for('show_image', id = id_minus) }}';">←</button>
|
||||
<button class="button_next float_right" onclick="location.href='{{ url_for('show_image', id = id_plus) }}';">→</button>
|
||||
<button class="button_continue" onclick="location.href='{{ url_for('show_image') }}';">continue</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img class=preload src="{{ url_for('static', filename=image_plus) }}" >
|
||||
{% endblock %}
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: '2'
|
||||
|
||||
|
||||
services:
|
||||
labeler:
|
||||
build:
|
||||
context: code
|
||||
ports:
|
||||
- "${EXPOSE}:8080"
|
||||
volumes:
|
||||
- "${DATAFOLDER}:/data/"
|
||||
environment:
|
||||
- UID=${UID}
|
||||
restart: unless-stopped
|
||||
5
example.env
Normal file
5
example.env
Normal file
@@ -0,0 +1,5 @@
|
||||
EXPOSE=8088
|
||||
DATAFOLDER=./data
|
||||
UID=1000
|
||||
SECRETKEY=K0gzwB+fYb1kfbsTzP5i
|
||||
|
||||
Reference in New Issue
Block a user