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