Make thumbnails on the server in the background

- The background worker now also gives models without a thumbnail one:
  an embedded 3MF/plate preview, else a picture uploaded with the model,
  else a 3MF preview or picture inside one of its archives, else a render
  of an STL/OBJ/3MF (loose or inside an archive).
- tools/render_thumbnail.py renders without a GPU: meshes are simplified
  per distinct part (the 8M-face lion project renders in ~8 s / ~200 MB),
  3MF projects show their first plate in filament colours, and only
  camera-facing faces are painted (inside-out meshes and mirrored parts are
  re-oriented first).
- models.thumbnail_status stops retries when there's nothing to use; adding
  files clears it. The browser now only renders FBX/STEP/3DM thumbnails.
- The library checks back while fingerprints or thumbnails are pending and
  refreshes when they land.
- Python sidecar needs pillow and fast-simplification; compose keeps the
  archive extraction cache in a volume. README covers the phase 2 features.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-15 07:54:26 +10:00
parent fcb418f119
commit ea2afeac0e
11 changed files with 375 additions and 12 deletions

View file

@ -1,6 +1,6 @@
# Printhive
A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP, GCODE, ZIP) into a searchable library — with in-browser 3D previews, collections, license/commercial-use tracking, and one-paste **import from popular model sites**.
A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX, STEP, 3DM, GCODE — loose or in ZIP/7Z/RAR/TAR archives) into a searchable library — with in-browser 3D previews, collections, license/commercial-use tracking, and one-paste **import from popular model sites**.
## Features
@ -10,8 +10,11 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
- **Printables** — downloads files automatically via its public GraphQL API (no auth needed for free models).
- **MakerWorld** — downloads files via FlareSolverr (solves Cloudflare) + your account token.
- **cubee3d (Hive)** and any other URL — imports title/description/preview image; you add the files manually.
- **In-browser 3D viewer** (Three.js) for STL/OBJ, and for 3MF via a server-side **trimesh GLB conversion** (handles Bambu/MakerWorld production-extension files the JS loader can't).
- **Auto thumbnails** rendered from the model; embedded 3MF thumbnails used when present.
- **In-browser 3D viewer** (Three.js) for STL, OBJ (with its MTL colours/textures), FBX (with textures), STEP/STP (OpenCascade WASM) and Rhino 3DM, and for 3MF via a server-side **trimesh GLB conversion** that streams even huge Bambu/MakerWorld projects.
- **Slicer projects plate by plate** — Bambu Studio / OrcaSlicer 3MFs open on plate 1 with the slicer's plate images as a switcher, parts shown in their filament colours, and a Filaments list; negative/modifier parts are hidden.
- **Archives as folders** — ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background; browse what's inside and preview or download any file without unpacking. Models inside archives count for duplicate detection.
- **Pictures, PDFs & readmes** — keep them with a model and view them in the app.
- **Auto thumbnails** made in the background: an embedded 3MF/plate preview, else a picture that came with the model (or one inside its archive), else a server-side render of the STL/OBJ/3MF; the browser renders FBX/STEP/3DM ones.
- **License & commercial-use tracking** — captured per source, with a "sellable / non-commercial" indicator (a guide; verify before selling).
- **Collections** — group models (a model can be in many); rename/delete without losing models.
- **Editing** — rename/retag, edit print settings, override commercial-use, refresh metadata from source, re-download files, download all files as a zip.
@ -31,10 +34,10 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, STEP
## Setup
1. **Postgres** — create a database and point `DATABASE_URL` at it. The schema is applied automatically on startup.
2. **Python sidecar** (for 3MF previews/volume and shape-based duplicate detection — without it, duplicates are still found by content hash):
2. **Python sidecar** (for 3MF previews, plates, server-side thumbnails and shape-based duplicate detection — without it, duplicates are still found by content hash):
```bash
python3 -m venv tools/venv
tools/venv/bin/pip install trimesh numpy networkx lxml
tools/venv/bin/pip install -r tools/requirements.txt
```
3. **Install deps and run**:
```bash

View file

@ -145,6 +145,10 @@ CREATE TABLE IF NOT EXISTS archive_entries (
CREATE INDEX IF NOT EXISTS idx_entries_sha256 ON archive_entries(sha256);
CREATE INDEX IF NOT EXISTS idx_entries_geometry ON archive_entries(geometry_hash);
-- Background thumbnails: null = not tried yet, none = nothing to make one from
-- (left to the browser), error. Cleared when files are added.
ALTER TABLE models ADD COLUMN IF NOT EXISTS thumbnail_status TEXT;
-- Duplicate groups marked "not duplicates" (signature = sorted model ids).
CREATE TABLE IF NOT EXISTS duplicate_dismissals (
signature TEXT PRIMARY KEY,

View file

@ -36,6 +36,7 @@ services:
- uploads:/app/uploads
- thumbnails:/app/thumbnails
- glb:/app/glb
- extracted:/app/extracted # cache of files unpacked from archives
# No host port published — Traefik auto-discovers the app on the shared network.
networks:
- default # talk to the db
@ -56,6 +57,7 @@ volumes:
uploads:
thumbnails:
glb:
extracted:
networks:
default:

View file

@ -10,6 +10,7 @@ const crypto = require('crypto');
const db = require('./db');
const store = require('./store');
const archives = require('./archives');
const thumbnails = require('./thumbnails');
const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor');
const { PYTHON, runScript } = require('./python');
@ -208,7 +209,8 @@ async function fingerprintEntries() {
async function run() {
for (;;) {
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries()];
// Thumbnails go last so they can use what's inside newly indexed archives.
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(), await thumbnails.generate()];
if (!worked.some(Boolean)) return;
}
}

View file

@ -269,6 +269,8 @@ async function dismissedDuplicateSignatures() {
async function addFiles(modelId, files) {
await db.transaction(async (client) => {
for (const file of files) await insertFile(client, modelId, file);
// New files may give the background worker something to make a thumbnail from.
await client.query('UPDATE models SET thumbnail_status = NULL WHERE id = $1', [modelId]);
});
return getModel(modelId);
}

104
lib/thumbnails.js Normal file
View file

@ -0,0 +1,104 @@
// Background thumbnails for models that don't have one. Tries, in order: an
// embedded 3MF preview, a picture uploaded with the model, a 3MF preview or
// picture inside one of its archives, then a server-side render of an
// STL/OBJ/3MF (or one inside an archive). Models whose only 3D files are
// FBX/STEP/3DM are left to the browser, which can render those.
const fs = require('fs');
const path = require('path');
const db = require('./db');
const archives = require('./archives');
const fileProcessor = require('./fileProcessor');
const { runScript } = require('./python');
const THUMBNAIL_DIR = './thumbnails';
const PICTURES = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
const RENDERABLE = ['3mf', 'stl', 'obj']; // 3MF first: slicer projects usually embed a preview
const MAX_ATTEMPTS = 3; // per kind of source, so a huge pack doesn't stall the queue
const BATCH = 5;
// Models still waiting: no thumbnail, not given up on, and archives already
// indexed (so what's inside them can be considered).
const PENDING = `
m.thumbnail_path IS NULL AND m.thumbnail_status IS NULL AND m.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.model_id = m.id AND f.format = ANY($1) AND f.archive_status IS NULL)`;
async function render(input, base) {
const out = path.join(THUMBNAIL_DIR, `${base}.png`);
fs.mkdirSync(THUMBNAIL_DIR, { recursive: true });
const { err } = await runScript('render_thumbnail.py', [input, path.resolve(out)],
{ timeout: 180000, isBackground: true });
if (err && err.code === 'ENOENT') throw err;
return !err && fs.existsSync(out) ? out : null;
}
// Returns a path under thumbnails/ or null.
async function thumbnailFor(modelId) {
const { rows: files } = await db.query(
'SELECT id, file_path, format FROM files WHERE model_id = $1 ORDER BY id', [modelId]);
const base = `model-${modelId}-${Date.now()}`;
for (const f of files.filter((x) => x.format === '3mf').slice(0, MAX_ATTEMPTS)) {
const out = fileProcessor.extract3mfThumbnail(path.resolve(f.file_path), base);
if (out) return out;
}
const picture = files.find((f) => PICTURES.includes(f.format));
if (picture) return fileProcessor.copyAsThumbnail(path.resolve(picture.file_path), base);
const archiveFiles = files.filter((f) => fileProcessor.ARCHIVE_FORMATS.includes(f.format));
const { rows: entries } = archiveFiles.length
? await db.query('SELECT id, file_id, path, format FROM archive_entries WHERE file_id = ANY($1::int[]) ORDER BY path',
[archiveFiles.map((f) => f.id)])
: { rows: [] };
const extract = (entry) => archives.extractEntry(archiveFiles.find((f) => f.id === entry.file_id), entry);
for (const e of entries.filter((x) => x.format === '3mf').slice(0, MAX_ATTEMPTS)) {
const out = fileProcessor.extract3mfThumbnail(await extract(e), base);
if (out) return out;
}
const entryPicture = entries.find((e) => PICTURES.includes(e.format));
if (entryPicture) return fileProcessor.copyAsThumbnail(await extract(entryPicture), base);
const sources = [
...RENDERABLE.flatMap((fmt) => files.filter((f) => f.format === fmt).map((f) => () => path.resolve(f.file_path))),
...RENDERABLE.flatMap((fmt) => entries.filter((e) => e.format === fmt).map((e) => () => extract(e))),
];
for (const source of sources.slice(0, MAX_ATTEMPTS)) {
const out = await render(await source(), base);
if (out) return out;
}
return null;
}
// One batch; returns whether there was anything to do.
async function generate() {
const { rows } = await db.query(
`SELECT m.id FROM models m WHERE ${PENDING} ORDER BY m.id LIMIT $2`, [fileProcessor.ARCHIVE_FORMATS, BATCH]);
for (const { id } of rows) {
let out = null;
let status = 'none';
try {
out = await thumbnailFor(id);
} catch (err) {
console.warn(`Thumbnail failed for model ${id}:`, err.message);
status = 'error';
}
if (out) {
// Don't overwrite a thumbnail set meanwhile (e.g. rendered by a browser).
const { rowCount } = await db.query(
'UPDATE models SET thumbnail_path = $2 WHERE id = $1 AND thumbnail_path IS NULL', [id, out]);
if (rowCount === 0) fs.rmSync(out, { force: true });
} else {
await db.query('UPDATE models SET thumbnail_status = $2 WHERE id = $1', [id, status]);
}
}
return rows.length > 0;
}
async function pendingCount() {
const { rows } = await db.query(`SELECT COUNT(*)::int AS n FROM models m WHERE ${PENDING}`,
[fileProcessor.ARCHIVE_FORMATS]);
return rows[0].n;
}
module.exports = { generate, pendingCount };

View file

@ -163,6 +163,7 @@ async function loadData() {
renderModels();
updateCounts();
generateMissingThumbnails(); // fire-and-forget; updates cards as they finish
watchBackgroundWork();
// First-run onboarding: prompt for import sources once.
if (config.setupComplete === false && !setupModal.classList.contains('active')) {
@ -173,19 +174,43 @@ async function loadData() {
}
}
// While the server is still fingerprinting or making thumbnails, check back
// every few seconds and reload once its progress changes, so new thumbnails
// and duplicates show up without a manual refresh.
let backgroundPoll = null;
function watchBackgroundWork() {
clearTimeout(backgroundPoll);
const pendingOf = (s) => (s.fingerprintsPending || 0) + (s.thumbnailsPending || 0);
const pending = pendingOf(stats);
if (!pending) return;
backgroundPoll = setTimeout(async () => {
try {
const next = await (await fetch('/api/library/stats')).json();
if (pendingOf(next) !== pending || next.duplicateGroups !== stats.duplicateGroups) {
await loadData(); // re-arms the watch
return;
}
stats = next;
watchBackgroundWork();
} catch { /* offline: try again on the next reload */ }
}, 5000);
}
async function loadTrash() {
const res = await fetch('/api/models?view=trash');
trashModels = res.ok ? await res.json() : [];
}
// Render thumbnails in the browser for models that don't have one yet, then
// persist them. Runs one at a time to avoid thrashing the GPU / WebGL contexts.
// The server makes STL/OBJ/3MF thumbnails in the background; the browser
// renders the formats only it can read, then saves them. One at a time, to
// avoid thrashing the GPU / WebGL contexts.
const BROWSER_THUMBNAIL_FORMATS = ['fbx', '3dm', 'step', 'stp'];
const thumbAttempted = new Set();
async function generateMissingThumbnails() {
if (!window.ModelViewer) return;
for (const m of models) {
if (m.thumbnailUrl || thumbAttempted.has(m.id)) continue;
const file = (m.files || []).find((f) => window.ModelViewer.RENDERABLE.includes(f.format));
const file = (m.files || []).find((f) => BROWSER_THUMBNAIL_FORMATS.includes(f.format));
if (!file) continue;
thumbAttempted.add(m.id);
try {

View file

@ -22,6 +22,7 @@ const mailer = require('./lib/mailer');
const fingerprint = require('./lib/fingerprint');
const duplicates = require('./lib/duplicates');
const archives = require('./lib/archives');
const thumbnails = require('./lib/thumbnails');
const app = express();
const PORT = process.env.PORT || 3000;
@ -1008,9 +1009,9 @@ async function findDuplicateGroups() {
app.get('/api/library/stats', async (req, res, next) => {
try {
const [trash, groups, pending] = await Promise.all([
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount()]);
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending });
const [trash, groups, pending, thumbnailsPending] = await Promise.all([
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount()]);
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending });
} catch (e) { next(e); }
});

79
test/thumbnail.test.js Normal file
View file

@ -0,0 +1,79 @@
// Server-side thumbnail rendering (tools/render_thumbnail.py).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const { PYTHON } = require('../lib/python');
const RENDER = path.join(__dirname, '..', 'tools', 'render_thumbnail.py');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-thumb-'));
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const lastJson = (stdout) => JSON.parse(stdout.trim().split('\n').pop());
// Size, how much of the image is covered, where, and how green/blue the model is.
function pixelStats(png) {
const script = `
import json, sys
from PIL import Image
im = Image.open(sys.argv[1]).convert("RGBA")
px = [p for p in im.getdata() if p[3] > 0]
opaque = Image.new("L", im.size); opaque.putdata([255 if p[3] > 0 else 0 for p in im.getdata()])
share = lambda test: sum(1 for p in px if test(p)) / max(len(px), 1)
from collections import Counter
top3 = sum(n for _, n in Counter(p[:3] for p in px if p[3] == 255).most_common(3)) / max(len(px), 1)
print(json.dumps({"size": im.size, "coverage": len(px) / (im.size[0] * im.size[1]), "bbox": opaque.getbbox(), "top3": top3,
"green": share(lambda p: p[1] > p[0] + 40 and p[1] > p[2] + 40),
"blue": share(lambda p: p[2] > p[0] + 40 and p[2] > p[1] + 40)}))`;
return JSON.parse(execFileSync(PYTHON, ['-c', script, png], { encoding: 'utf8' }));
}
test('an STL renders to a 512px PNG, fully in frame', { skip }, () => {
const out = path.join(tmp, 'cube.png');
const result = lastJson(execFileSync(PYTHON, [RENDER, fixture('cube.stl'), out], { encoding: 'utf8' }));
assert.equal(result.faces, 12);
const stats = pixelStats(out);
assert.deepEqual(stats.size, [512, 512]);
assert.ok(stats.coverage > 0.1 && stats.coverage < 0.9, `coverage ${stats.coverage}`);
const [left, top, right, bottom] = stats.bbox;
assert.ok(left > 2 && top > 2 && right < 510 && bottom < 510, `bbox ${stats.bbox}`);
});
// A cube shows three faces, each one flat shade; hidden faces painted over
// visible ones (or inside-out winding) would add more shades.
for (const inverted of [false, true]) {
test(`a cube${inverted ? ' with inside-out faces' : ''} shows just its three visible faces`, { skip }, () => {
let input = fixture('cube.stl');
if (inverted) {
input = path.join(tmp, 'inverted.stl');
execFileSync(PYTHON, ['-c', `
import sys, trimesh
m = trimesh.load(sys.argv[1], force="mesh"); m.invert(); m.export(sys.argv[2])`, fixture('cube.stl'), input]);
}
const out = path.join(tmp, `cube-${inverted}.png`);
execFileSync(PYTHON, [RENDER, input, out], { encoding: 'utf8' });
const { top3 } = pixelStats(out);
assert.ok(top3 > 0.9, `three most common colours cover ${top3}`);
});
}
test('a multi-plate 3MF renders its first plate in filament colours', { skip }, () => {
const out = path.join(tmp, 'plates.png');
lastJson(execFileSync(PYTHON, [RENDER, fixture('plates.3mf'), out], { encoding: 'utf8' }));
const stats = pixelStats(out);
assert.ok(stats.green > 0.5, `green share ${stats.green}`); // plate 1's cube prints in #00FF00
assert.ok(stats.blue < 0.01, `blue share ${stats.blue}`); // plate 2's blue tower isn't drawn
});
test('a file with no geometry fails with a JSON error', { skip }, () => {
const broken = path.join(tmp, 'broken.stl');
fs.writeFileSync(broken, 'this is not a mesh');
const run = spawnSync(PYTHON, [RENDER, broken, path.join(tmp, 'x.png')], { encoding: 'utf8' });
assert.equal(run.status, 1);
assert.match(lastJson(run.stdout).error, /could not render thumbnail/);
});

139
tools/render_thumbnail.py Normal file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Render a model's card thumbnail without a GPU.
Usage: render_thumbnail.py <input.stl|.obj|.3mf> <output.png> [--size 512]
Prints {png, faces} (or {error}) as JSON.
3MF projects show their first plate with parts in their filament colours. Big
meshes are simplified first, then triangles are painted back to front with
Pillow, which is plenty for a card image.
"""
import argparse
import json
import fast_simplification
import numpy as np
import trimesh
from PIL import Image, ImageDraw
import threemf_stream
MAX_FACES = 150_000
GREY = (196, 202, 214)
SUPERSAMPLE = 2
def simplify(vertices, faces, ratio):
if ratio >= 1.0 or len(faces) <= 400:
return vertices, faces
v, f = fast_simplification.simplify(
np.asarray(vertices, dtype=np.float32), np.asarray(faces, dtype=np.int32), target_reduction=1 - ratio)
return np.asarray(v, dtype=np.float64), np.asarray(f)
def hex_rgb(colour):
return tuple(int(colour[i:i + 2], 16) for i in (1, 3, 5)) if colour else GREY
def load_3mf(path):
meshes, placements = threemf_stream.load(path)
project = threemf_stream.read_project(path)
placements = threemf_stream.printable(project, placements)
if len(project["plates"]) > 1:
placements = threemf_stream.on_plate(project, placements, project["plates"][0]["index"]) or placements
if not placements:
raise ValueError("no geometry found")
# Simplify each distinct part once, by the same ratio, so instances stay cheap.
total = sum(len(meshes[p.key][1]) for p in placements)
ratio = min(1.0, MAX_FACES / total)
small = {}
vs, fs, cs, offset = [], [], [], 0
for p in placements:
if p.key not in small:
small[p.key] = simplify(*meshes[p.key], ratio)
v, f = small[p.key]
t = p.transform
if np.linalg.det(t[:3, :3]) < 0:
f = f[:, ::-1] # a mirrored placement flips winding; flip it back so faces point outward
vs.append(v @ t[:3, :3].T + t[:3, 3])
fs.append(f + offset)
cs.append(np.tile(hex_rgb(threemf_stream.filament_colour(project, p)), (len(f), 1)))
offset += len(v)
return np.vstack(vs), np.vstack(fs), np.vstack(cs)
def load_mesh(path):
mesh = trimesh.load(path, force="mesh")
if mesh is None or len(mesh.faces) == 0:
raise ValueError("no geometry found")
v, f = simplify(np.asarray(mesh.vertices), np.asarray(mesh.faces), min(1.0, MAX_FACES / len(mesh.faces)))
if path.lower().endswith(".obj"):
v = np.column_stack([v[:, 0], -v[:, 2], v[:, 1]]) # OBJ is Y-up; everything else here is Z-up
return v, f, np.tile(GREY, (len(f), 1))
def render(vertices, faces, colours, size):
# Three-quarter view from the front right, a little above.
az, el = np.radians(-40), np.radians(30)
forward = -np.array([np.cos(el) * np.sin(az), -np.cos(el) * np.cos(az), np.sin(el)])
right = np.cross(forward, [0, 0, 1])
right /= np.linalg.norm(right)
up = np.cross(right, forward)
a, b, c = vertices[faces[:, 0]], vertices[faces[:, 1]], vertices[faces[:, 2]]
normals = np.cross(b - a, c - a)
# A mesh exported inside-out has negative volume: turn its normals around.
if np.einsum("ij,ij->i", a, np.cross(b, c)).sum() < 0:
normals = -normals
normals /= np.linalg.norm(normals, axis=1, keepdims=True) + 1e-12
# Only faces pointing at the camera are drawn. Painting back faces too lets a
# large far triangle land on top of a near one when sorted by centre depth.
facing = normals @ forward < 0
if facing.any():
a, b, c, normals, colours = a[facing], b[facing], c[facing], normals[facing], colours[facing]
light = -forward + 0.6 * up - 0.4 * right
light /= np.linalg.norm(light)
shade = 0.32 + 0.68 * np.clip(normals @ light, 0, 1)
fills = np.clip(colours * shade[:, None], 0, 255).astype(np.uint8)
xs = np.stack([a @ right, b @ right, c @ right], axis=1)
ys = np.stack([a @ up, b @ up, c @ up], axis=1)
lo = np.array([xs.min(), ys.min()])
span = np.array([xs.max(), ys.max()]) - lo
width = size * SUPERSAMPLE
scale = width * 0.86 / max(span.max(), 1e-9)
offset = (width - span * scale) / 2
px = (xs - lo[0]) * scale + offset[0]
py = width - ((ys - lo[1]) * scale + offset[1])
image = Image.new("RGBA", (width, width), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
depth = ((a + b + c) / 3) @ forward
for i in np.argsort(-depth): # far to near
fill = tuple(int(x) for x in fills[i]) + (255,)
draw.polygon([(px[i, 0], py[i, 0]), (px[i, 1], py[i, 1]), (px[i, 2], py[i, 2])], fill=fill, outline=fill)
return image.resize((size, size), Image.LANCZOS)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input")
parser.add_argument("output")
parser.add_argument("--size", type=int, default=512)
args = parser.parse_args()
try:
load = load_3mf if args.input.lower().endswith(".3mf") else load_mesh
vertices, faces, colours = load(args.input)
render(vertices, faces, colours, args.size).save(args.output, "PNG")
except Exception as exc:
print(json.dumps({"error": f"could not render thumbnail: {str(exc)[:200]}"}))
return 1
print(json.dumps({"png": args.output, "faces": int(len(faces))}))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2,3 +2,5 @@ trimesh
numpy
networkx
lxml
pillow
fast-simplification