implement save/load

pull/5/head
hnmr293 2023-02-21 00:37:58 +09:00
parent cac16b60ed
commit a054e8f78d
7 changed files with 376 additions and 18 deletions

5
.gitignore vendored
View File

@ -1,3 +1,4 @@
__pycache__
.vscode
venv
.vscode/
venv/
saved_poses/

120
app.py
View File

@ -2,12 +2,130 @@ if __name__ == '__main__':
import mimetypes
mimetypes.add_type('application/javascript', '.js')
from flask import Flask
import os, sys, importlib, glob, json, base64, traceback, re
from functools import wraps
from io import BytesIO
from flask import Flask, jsonify, request
if importlib.util.find_spec('PIL') is not None:
from PIL import Image, PngImagePlugin
else:
import subprocess
try:
print('-' * 80, file=sys.stderr)
print('| installing PIL (pillow) ...')
print('-' * 80, file=sys.stderr)
subprocess.check_call(
[sys.executable, "-m", "pip", "install", 'pillow'],
stdout=sys.stdout,
stderr=sys.stderr
)
except Exception as e:
msg = ''.join(traceback.TracebackException.from_exception(e).format())
print(msg, file=sys.stderr)
print('-' * 80, file=sys.stderr)
print('| failed to install PIL (pillow). exit...', file=sys.stderr)
print('-' * 80, file=sys.stderr)
sys.exit(1)
from PIL import Image, PngImagePlugin
app = Flask(__name__, static_folder='.', static_url_path='')
def atoi(text):
return int(text) if text.isdigit() else text
def natural_keys(text):
return [ atoi(c) for c in re.split(r'(\d+)', text) ]
def sorted_glob(path):
return sorted(glob.glob(path), key=natural_keys)
def get_saved_poses():
dirpath = os.path.join(app.static_folder, 'saved_poses')
for path in sorted_glob(f'{dirpath}/*.png'):
yield Image.open(path)
@app.route('/')
def index():
return app.send_static_file('index.html')
def api_try(fn):
@wraps(fn)
def f(*args, **kwargs):
try:
return fn(*args, **kwargs)
except Exception as e:
msg = ''.join(traceback.TracebackException.from_exception(e).format())
print(msg, file=sys.stderr)
return jsonify(result=str(e), ok=False)
return f
@app.route('/pose/all')
@api_try
def saved_poses():
result = []
for img in get_saved_poses():
buffer = BytesIO()
img.save(buffer, format='png')
if not hasattr(img, 'text'):
continue
result.append({
'name': img.text['name'],
'image': base64.b64encode(buffer.getvalue()).decode('ascii'),
'screen': json.loads(img.text['screen']),
'camera': json.loads(img.text['camera']),
'joints': json.loads(img.text['joints']),
})
return jsonify(result)
def name2path(name: str):
if not isinstance(name, str):
raise ValueError(f'str object expected, but {type(name)}')
if len(name) == 0:
raise ValueError(f'empty name')
if '.' in name or '/' in name or '\\' in name:
raise ValueError(f'invalid name: {name}')
path = os.path.realpath(os.path.join(app.static_folder, 'saved_poses', f'{name}.png'))
prefix = os.path.realpath(os.path.join(app.static_folder, 'saved_poses'))
if not path.startswith(prefix):
raise ValueError(f'invalid name: {name}')
return path
@app.route('/pose/save', methods=['POST'])
@api_try
def save_pose():
data = request.json
name = data['name']
screen = data['screen']
camera = data['camera']
joints = data['joints']
info = PngImagePlugin.PngInfo()
info.add_text('name', name)
info.add_text('screen', json.dumps(screen))
info.add_text('camera', json.dumps(camera))
info.add_text('joints', json.dumps(joints))
filepath = name2path(name)
image = Image.open(BytesIO(base64.b64decode(data['image'][len('data:image/png;base64,'):])))
unit = max(image.width, image.height)
mx, my = (unit - image.width) // 2, (unit - image.height) // 2
canvas = Image.new('RGB', (unit, unit), color=(68, 68, 68))
canvas.paste(image, (mx, my))
image = canvas.resize((canvas.width//4, canvas.height//4))
image.save(filepath, pnginfo=info)
return jsonify(result='pose saved', ok=True)
@app.route('/pose/delete', methods=['POST'])
@api_try
def delete_pose():
data = request.json
filepath = name2path(data['name'])
os.remove(filepath)
return jsonify(result='pose deleted', ok=True)
app.run(port=55502, debug=True)

View File

@ -68,4 +68,58 @@
position: absolute;
outline: 1px solid gray;
pointer-events: none;
}
#saved_poses {
display: flex;
flex-direction: row;
gap: 0.25em;
font-size: small;
}
#saved_poses > * {
margin: 0;
outline: 1px solid gray;
max-width: 128px;
}
#saved_poses img {
max-width: 128px;
max-height: 128px;
}
#saved_poses figcaption {
padding: 0 0.25em;
overflow-wrap: anywhere; /* Opera Android may not be able to interpret `anywhere` keyword. */
}
#saved_poses .close {
position: absolute;
cursor: pointer;
border: 1px solid gray;
background-color: white;
opacity: 0.5;
width: 1.25em;
height: 1.25em;
text-align: center;
vertical-align: middle;
margin: 0;
font-family: monospace;
}
#saved_poses .close:hover {
opacity: 1.0;
}
#saved_poses .close2 {
display: none;
position: absolute;
left: 1.5em;
top: 0;
}
#saved_poses .close:hover .close2 {
display: block;
color: white;
pointer-events: none;
}

View File

@ -52,8 +52,12 @@
</div>
<div id="body_indicator2"></div>
<div id="body_indicator1"></div>
<a id="save_button" class="box" href="#">&#x1f4be; Save</a>
<a id="copy_button" class="box" href="#">&#x1f4cb; Copy to clipboard</a>
<div style="display: flex; flex-direction: row; gap: 0 0.25em;">
<a id="save_button" class="box" style="flex: 1 1 0;" href="#">&#x1f4be; Save Image</a>
<a id="copy_button" class="box" style="flex: 1 1 0;" href="#">&#x1f4cb; Copy to clipboard</a>
</div>
<button id="save_pose" class="box">&#x1f4be;&#x1f9cd; Save Pose</button>
<div id="saved_poses"></div>
</div>
<p id="notation"></p>
<div id="notifications"></div>

129
js/app.js
View File

@ -1,5 +1,80 @@
const POSES = new Map();
function notify(str, type) {
if (type === undefined) type = 'success';
switch (type) {
case 'success': console.log(str); break;
case 'info': console.log(str); break;
case 'warn': console.warn(str); break;
case 'error': console.error(str); break;
}
const p = document.createElement('p');
p.textContent = str;
p.classList.add('item', type);
const cont = document.querySelector('#notifications');
cont.appendChild(p);
setTimeout(() => cont.removeChild(p), 3000);
}
async function save_pose(obj) {
const res = await fetch('/pose/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(obj),
});
const result = await res.json();
if (result.ok) reload_poses();
return result;
}
async function delete_pose(name) {
const res = await fetch('/pose/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const result = await res.json();
notify(result.result, result.ok ? 'success' : 'error');
if (result.ok) reload_poses();
return result;
}
async function reload_poses() {
POSES.clear();
const res = await fetch('/pose/all');
const cont = document.querySelector('#saved_poses');
cont.innerHTML = '';
const df = document.createDocumentFragment();
for (let data of await res.json()) {
POSES.set(data.name, data);
const fig = document.createElement('figure')
const img = document.createElement('img');
const cap = document.createElement('figcaption');
const clo = document.createElement('div');
const clo2 = document.createElement('span');
fig.dataset.poseName = data.name;
cap.textContent = data.name;
clo.textContent = 'x';
clo.classList.add('close');
clo2.classList.add('close2');
clo2.textContent = 'delete';
clo.appendChild(clo2);
img.src = 'data:image/png;base64,' + data.image;
img.title = data.name;
fig.append(clo, img, cap);
df.appendChild(fig);
}
cont.appendChild(df);
}
document.addEventListener('DOMContentLoaded', async () => {
const { init, init_3d } = await import('posex');
const ui = {
container: document.querySelector('#cont'),
canvas: document.querySelector('#main_canvas'),
@ -17,21 +92,49 @@ document.addEventListener('DOMContentLoaded', async () => {
reset_bg: document.querySelector('#reset_bg'),
save: document.querySelector('#save_button'),
copy: document.querySelector('#copy_button'),
notify: function () {
function notify(str, type) {
if (type === undefined) type = 'success';
const p = document.createElement('p');
p.textContent = str;
p.classList.add('item', type);
const cont = document.querySelector('#notifications');
cont.appendChild(p);
setTimeout(() => cont.removeChild(p), 3000);
}
},
save_pose: document.querySelector('#save_pose'),
save_pose_callback: save_pose,
notify: notify,
};
document.addEventListener('poseload', e => {
const obj = POSES.get(e.detail.name);
if (obj) ui.loadPose(obj);
}, false);
const { init, init_3d } = await import('posex');
init(ui);
const animate = init_3d(ui);
animate();
await reload_poses();
}, false);
document.addEventListener('DOMContentLoaded', () => {
const get_name = ele => {
while (ele && ele !== document) {
if (ele.dataset && ele.dataset.poseName !== undefined)
return ele.dataset.poseName;
ele = ele.parentNode;
}
return '';
};
document.querySelector('#saved_poses').addEventListener('click', e => {
let target = e.target;
if (target.tagName === 'IMG') target = target.parentNode;
if (target.classList.contains('close2')) target = target.parentNode;
if (target.tagName === 'FIGURE') {
const name = get_name(target);
const ev = new CustomEvent('poseload', { bubbles: true, detail: { name } });
target.dispatchEvent(ev);
} else if (target.classList.contains('close')) {
const name = get_name(target);
if (name.length != 0) {
delete_pose(name);
}
}
}, false);
}, false);

View File

@ -233,6 +233,8 @@ function init_3d(ui) {
bodies.set(name, body);
scene.add(group);
touchable_bodies.push(group);
return body;
};
const remove_body = name => {
@ -445,6 +447,53 @@ function init_3d(ui) {
if (ui.reset_bg)
ui.reset_bg.addEventListener('click', () => set_bg(null), false);
function get_pose_dict(obj3d) {
return {
position: obj3d.position.toArray(),
rotation: obj3d.rotation.toArray(),
scale: obj3d.scale.toArray(),
up: obj3d.up.toArray(),
};
}
function set_pose_dict(obj3d, dict) {
obj3d.position.set(...dict.position);
obj3d.rotation.set(...dict.rotation);
obj3d.scale.set(...dict.scale);
obj3d.up.set(...dict.up);
}
if (ui.save_pose && ui.save_pose_callback)
ui.save_pose.addEventListener('click', async () => {
const name = prompt('Input pose name.');
if (name === undefined || name === null || name === '') return;
const screen = {
width: width(),
height: height(),
}
const camera_ = get_pose_dict(camera);
const joints = [];
for (let [name, body] of bodies) {
joints.push({
name,
joints: body.joints.map(j => get_pose_dict(j)),
group: get_pose_dict(body.group),
x0: body.x0,
y0: body.y0,
z0: body.z0,
});
}
const image = await ui.getDataURL();
const data = { name, image, screen, camera: camera_, joints };
const result = await ui.save_pose_callback(data);
ui.notify(result.result, result.ok ? 'success' : 'error');
}, false);
const onAnimateEndOneshot = [];
const animate = () => {
@ -499,6 +548,34 @@ function init_3d(ui) {
onAnimateEndOneshot.length = 0;
};
ui.loadPose = function(data) {
selected_body = null;
touched_body = null;
touchable_objects.length = 0;
touchable_bodies.length = 0;
object_to_body.clear();
for (let name of bodies.keys()) {
remove_body(name);
}
// screen
size_change(data.screen.width, data.screen.height);
if (width_input) width_input.value = data.screen.width;
if (height_input) height_input.value = data.screen.height;
// camera
set_pose_dict(camera, data.camera);
// bodies
for (let dict of data.joints) {
const body = add_body(dict.name, dict.x0, dict.y0, dict.z0);
for (let i = 0, e = Math.min(body.joints.length, dict.joints.length); i < e; ++i) {
set_pose_dict(body.joints[i], dict.joints[i]);
}
set_pose_dict(body.group, dict.group);
}
};
ui.getDataURL = async function() {
const pr = new Promise(resolve => {
const current_bg = scene.background;

View File

@ -1 +1,2 @@
flask
pillow