Skip to content

About

Photographs to a textured mesh on one 8 GB NVIDIA card, offline. Built on TRELLIS.2, with a four view path, and the photographs' own pixels projected onto the texture so printed text stays readable.

Topics

Resources

Contributing

Stars

45 stars

Watchers

1 watching

Forks

Repository files navigation

LocalMesh Engine

Version License Python CUDA Weights

LocalMesh Engine turns one photograph, or four sides of the same subject, into a textured .glb file on a single NVIDIA card with 8 GB of memory, with no generation service in the loop.

Install guide  ·  Changelog  ·  Weights on Hugging Face  ·  Project page  ·  LocalMesh, the application

New in 1.2

The photograph, the texture drawn by the model, and the photograph projected onto it: printed text stays readable

The photographs' own pixels are now projected onto the texture after the model has drawn it, so printed text, logos and fine patterns come back as they are on the photograph instead of redrawn. When the projection painted something, the run also keeps the drawn texture, as model_<seed>.ai.glb. --no-projection turns the projection off. Floaters are removed in one batched pass (170 s to 13 s on one subject), and a very heavy subject textured from a single photograph no longer overflows an 8 GB card. Details and measurements in the changelog.

What it adds  ·  Results  ·  Install  ·  Use  ·  Quality tiers  ·  Licence

Four photographs of one subject go in, one textured .glb comes out
python -m localmesh_engine face.png --right right.png --left left.png --back back.png --to out/

One photograph works too, and is the shorter install: python -m localmesh_engine photo.png --to out/.


What this adds to TRELLIS.2

TRELLIS.2 is Microsoft Research's image to 3D model. This repository is the generation core of LocalMesh built on it, plus seven things TRELLIS.2 does not do.

Four photographs of one subject. TRELLIS.2 works from a single image. The four view path fuses front, right, left and back on one shared grid, at the sparse structure stage and again through the shape cascade, so the fusion is done by a network trained for it rather than by averaging afterwards. The multi-view weights are FP8 conversions of TencentARC's Pixal3D (MIT); the ported path follows visualbruno's ComfyUI node (MIT). It runs on the same 8 GB: the weights load one after another and the card is handed back between stages. What it costs is disk. The texture stage averages one prediction per view at every step. A single conditioning sequence for the four photographs measured sharper, but lost a blind comparison, and stays behind LUMENGEN_TEXTURE_4VUES=ensemble.

The views are measured, not declared. Three facts about the photographs are read from the images instead of being assumed. The azimuth each shot was taken from, using Depth Anything 3, so a hand held turn that is not a regular quarter turn still projects to the right pixels. For the shape, field of view, distance and elevation stay nominal: a turn photographed from above is not corrected there. The texture projection below fits one elevation shared by the views. Which side each profile actually shows, as one binary answer, kept only when two pose heads agree and the two profiles come out on opposite sides, and reported as unknown otherwise. And the crop, which is sized on the HEIGHT of the subject, a quantity a rotation around the vertical does not change, so the four views reach the model at one scale. Measured on four subjects: with a per view crop, an elongated subject arrives in profile 1.4 times smaller than in front view.

The photograph's own pixels on the texture. The texture the model returns is drawn: close to the photograph in colour and shape, wrong in the detail, so printed text comes back as glyphs that read as nothing. After texturing, every texel that a photograph sees well takes that photograph's pixel; at grazing angles the drawn texture shows through. The cameras are recovered from the images: azimuths from Depth Anything 3 with four photographs, searched over the full turn against the silhouette with one; size and centring from the silhouette; one elevation shared by all views. A texel is visible from a photograph when a render of the UV coordinates from that camera returns the texel itself at its pixel, so no depth tolerance is involved, and pixels next to a depth edge are left alone. Each texel takes the one photograph that sees it most squarely. Where no photograph sees the surface, the drawn texture stays, its colour pulled towards the photographed surface next to it. On a cassette generated from four photographs, 59 % of the surface takes a photograph pixel and the printed label reads as on the photographs. --no-projection keeps the drawn texture alone, the right choice for a subject whose reconstructed shape departs from the photograph in places.

Debris is removed by position, not by size. A component leaves only if it is both small against the largest component and detached from it, distance taken as the median of its vertices to the main shell rather than the minimum. On the samurai bench subject the mesh carries 1,133 components, 941 of which lie flat on the shell. Dropping the small ones would open 941 holes in the surface.

Materials a viewer can read. On the texturing path metalness is written as zero, because the model's output in that channel measures as noise. The glTF alpha mode is read off the alpha the model produced, so an opaque object ships opaque instead of ghosting whole. doubleSided follows the count of open edges rather than being set for every asset. At tile boundaries of the dual grid extraction, exactly one tile owns each quad, so the same oriented faces are not emitted twice.

8 GB is the target. FP8 weights, staged loading, and budgets derived from the card rather than fixed in advance. The token ceiling is 2,600 per gigabyte of video memory, so 20,800 on an 8 GB card, where the vendored default is 49,152, a number set for a datacenter card. It is the guard on the 1536 cascade: at a shape grid of 1024 the truncation loop never goes below the grid, so the ceiling does not bite on the shape there. Since 1.2.0 the same ceiling also guards the texture from a single photograph: above it, the mesh is encoded at 512 and textured by the 512 texture model (a leafy planter went from 45 min to 363 s, peak 10.8 to 2.7 GB). The four photograph texture is not covered. The face budget follows the same rule. When a shape overruns its budget the job resumes one tier down with the same seed and that tier's full recipe, and the mesh note says so, instead of failing.

Usable in a commercial product. The UV atlas rasteriser is rewritten in PyTorch (localmesh_engine/uvraster.py). nvdiffrast, whose NVIDIA licence limits use to research and evaluation, is called nowhere: every rasterisation in this repository goes through that file, in the vendored TRELLIS.2 and o_voxel code as much as in ours. pymeshlab (GPL-3) and meshlib (non commercial agreement) are neither imported nor called. xformers is replaced by a stand-in module that answers with PyTorch's own scaled dot product attention. plyfile (GPL-3.0), which the o_voxel wheel imports at module level, is replaced by a stand-in, leurre_plyfile.py, that raises if anything calls it. utils3d and o_voxel still declare it, so pip installs it: pip uninstall -y plyfile keeps it out (docs/INSTALL.md, step 4).

Measured times

RTX 4060 Laptop, 8 GB, six subjects, four photographs each, measured on engine 1.1.0.

Tier Views Time
Standard, standard four 6 min 30 to 8 min 30
Detailed, high four 9 min 20 to 13 min

Texture is roughly 60 % of that time. These totals have not been measured again for 1.2.0. The photograph projection it adds took 29 to 73 s in the seven runs logged on the same card, and the floater pass got shorter; how the two balance out over a whole run is not measured yet.

Peak memory is read from the card through NVML rather than from the PyTorch allocator, because the atlas bake allocates outside it. When NVML cannot answer, the run falls back to the allocator figure, and GenerateResult.vram_portee says which of the two the number came from.


Results

Six subjects, four photographs each, generated at the Detailed tier

Six subjects, four photographs each, Detailed tier (high), RTX 4060 Laptop 8 GB.

Four of the six meshes on a full turntable

A full turn each. One pose can be chosen; a full turn cannot.


Install

Python 3.12, and a torch that the compiled extensions were built against.

git clone https://github.com/Quentincls/localmesh-engine
cd localmesh-engine

python -m venv .venv && . .venv/bin/activate     # Windows: . .venv/Scripts/activate
pip install torch==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cu128
pip install -e .                                 # ".[multiview]" instead, for the four view path
pip uninstall -y plyfile                         # GPL-3.0, pulled in by utils3d; never used

export LOCALMESH_ROOT=/path/to/runtime           # the folder that holds models/
# Windows PowerShell: $env:LOCALMESH_ROOT = "C:\path\to\runtime"

Full walkthrough in docs/INSTALL.md, and where every weight file goes in docs/WEIGHTS.md. Nothing generates until the three CUDA extensions below are built. On Windows with Python 3.12 and torch 2.8.0+cu128 they are published prebuilt, with natten. Two obstacles are worth naming here.

Three CUDA extensions are not on PyPI. o_voxel is the o-voxel/ subfolder of github.com/microsoft/TRELLIS.2; cumesh and flex_gemm have repositories of their own, CuMesh and FlexGEMM. All three are built against torch==2.8.0+cu128 and Python 3.12. Another torch and they fail as a missing DLL. Note that pip install cumesh SUCCEEDS and installs a different project of the same name, after which you get an unreadable API error rather than a clean failure. Third party Linux wheels exist at siraxe/TRELLIS.2-4B_cuda_12.8.r12.8_wheels (cp312, linux_x86_64); they are not ours.

DINOv3 is gated on manual approval. The image encoder, facebook/dinov3-vitl16-pretrain-lvd1689m, requires a Hugging Face account, a request, and a human review on Meta's side. It is the longest step of the install and the only one that cannot be shortened. Start it first.

One piece is fetched at run time and not by hand: BiRefNet_HR, the subject cutout, is pulled from the Hub on the first generation if the cache is empty, and loaded with trust_remote_code=True. Everything else has to be in place before the engine starts.


Use

# one photograph
python -m localmesh_engine photo.png --to out/

# four sides of the same subject
python -m localmesh_engine face.png --right right.png --left left.png --back back.png --to out/

# a tier and a fixed seed
python -m localmesh_engine photo.png --tier high --seed 42 --to out/

# the drawn texture only, without the photographs projected onto it
python -m localmesh_engine photo.png --no-projection --to out/

--tier takes draft, standard, high or max, and defaults to standard. --seed at -1 draws a random one. The projection of the photographs is on unless --no-projection is given.

The code is written in French, and every flag also answers to its French name: --droite, --gauche, --dos, --palier, --graine, --vers, --sans-projection. The two spellings are the same flag. Progress is written to standard error, one line per stage, so the result path on standard output stays clean. The four view path wants all three sides: an incomplete set is refused rather than silently downgraded.

The Python API is the same engine, without the process boundary.

from pathlib import Path
from localmesh_engine import Engine, GenerateSettings

engine = Engine()

settings = GenerateSettings(
    images=[Path("face.png")],
    vues={"droite": Path("right.png"),
          "gauche": Path("left.png"),
          "dos": Path("back.png")},
    detail="standard",
    seed=42,
    projection_texture=True,   # False keeps the drawn texture
)

result = engine.generate(settings, Path("out/"))
print(result.glb_path, result.faces, result.peak_vram_gb)

generate() also takes progress, should_cancel and on_preview callbacks. GenerateResult carries the mesh counts, the wall clock per stage, the peak memory read from the card, and the notes the run produced.

Each run writes model_<seed>.glb and, when the projection painted something, model_<seed>.ai.glb beside it: the same mesh with the texture as the model drew it, about the same size, so expect about twice the disk per run. GenerateResult.glb_ai, and "glb_ai" in as_dict(), name it; they are None when the projection was off, failed, or covered nothing.


Reproduce the samurai

examples/samurai/ carries the four photographs the banner and the gallery were made from. They were generated with ChatGPT Image, so they ship with the repository.

python -m localmesh_engine examples/samurai/front.png   --right examples/samurai/right.png   --left examples/samurai/left.png   --back examples/samurai/back.png   --tier standard --seed 101 --to out/

On an RTX 4060 Laptop with 8 GB this took 8 minutes with engine 1.1.0, before the photograph projection, and returned a mesh of about 148,000 faces, peaking at 5.9 GB of video memory. The roles matter: the engine measures the angles it was given, but front has to be the photograph that carries the shape.

Quality tiers

A tier is a contract, not a slider. Changing one of these values changes the contract, and therefore the version of the engine.

Draft Standard Detailed Extreme
Identifier draft standard high max
Shape grid 512 1024 1024 1536 cascade
Steps, the same for structure, shape and texture 8 12 16 16
Texture atlas 2048 4096 4096 8192
Face budget 80,000 150,000 250,000 400,000
Texture guidance 1 5 5 5
Memory required none none none 24 GB
Four view path yes yes yes older blend

Standard is the default. From Draft to Standard everything doubles, and that is the visible jump. From Standard to Detailed the grid does not move: you buy steps and triangles, not silhouette. Extreme is the only tier that raises the grid, and the only one that asks for memory. Its 24 GB floor is derived from how memory follows cell count, not observed: no card that size has run it here.

The four view path serves Draft, Standard and Detailed. Its shape cascade always ends at 1024, so on Draft it returns more than the tier promises and takes longer for it. The multi-view weights exist at 512 and 1024 only, so Extreme falls back to the older blend rather than deliver a Standard shape under another label. That older blend also runs when the multi-view weights are simply not in place: four photographs still produce a mesh, and a note on the result says why the older blend was used, missing weights or a grid the ported path cannot produce.

Full table, budgets derived from the card, and the rule for changing a recipe: docs/RECIPES.md. What a change has to carry before it is read: CONTRIBUTING.md.


What it needs

  • An NVIDIA card with 8 GB of video memory. Eight is the size the recipes were fitted to, and the peak figures above were measured there.
  • Windows or Linux.
  • Python 3.12, with torch==2.8.0 built for CUDA 12.8.
  • Disk for the weights: about 9.5 GB for the single photograph path, 14.3 GB with the four views. File by file in docs/WEIGHTS.md.

Licence and credits

This code is Apache-2.0. See LICENSE. Code copied from other projects keeps its own licence, and every one of them is a free licence.

The code sits under localmesh_engine/, and the paths below are relative to it.

Part Origin Licence
_vendu_trellis2/ TRELLIS.2 inference code, Microsoft Research MIT, Copyright (c) Microsoft Corporation
_vendu_trellis2/, multi-view files TencentARC/Pixal3D MIT, Copyright (c) 2026 Tencent
_vendu_trellis2/, ported multi-view path visualbruno, ComfyUI-Trellis2 MIT
_vendu_o_voxel/ o_voxel, the PBR volume to UV bake of TRELLIS.2, Microsoft Research MIT, Copyright (c) Microsoft Corporation
multivue/champ/ valeoai/NAF, revision 37f2dfc Apache-2.0
_vendu_moge/ MoGe, Microsoft Research MIT, Copyright (c) Microsoft Corporation
_vendu_moge/model/dinov2/ DINOv2, Meta Apache-2.0

No weights are stored in this git repository. The FP8 multi-view conversions we produced are served from huggingface.co/Qtn-Cls/LocalMeshEngine and are conversions of the official BF16 Pixal3D weights: weight RMSE 0.025 to 0.026 against the source, projection probe 0.027. The other sets the engine needs, except DINOv3 and BiRefNet_HR, are rehosted unchanged in the same repository, so that one download fetches them all (docs/WEIGHTS.md). Each keeps its own licence, listed with its author's address in NOTICE.

Built with DINOv3. Using the DINOv3 image encoder carries conditions from Meta: anyone redistributing those weights ships a copy of the agreement with them, requires the recipient to accept the same terms, and displays this line somewhere visible. We do not redistribute them; the line is due all the same, and it stays due in any application built on this engine.


Citation

@software{colus2026localmeshengine,
  author  = {Colus, Quentin},
  title   = {LocalMesh Engine},
  year    = {2026},
  version = {1.2.0},
  license = {Apache-2.0},
  url     = {https://github.com/Quentincls/localmesh-engine}
}

The same entry is in CITATION.cff, which GitHub reads.

The upstream work this engine is built on, to be cited alongside it. Both projects publish their own entry, with their own authors and title: take it from the source rather than from here.


LocalMesh, the application built on this engine, is at local-mesh.com: the same generation core with an interface, a library and a viewer, for people who would rather not install any of the above.

The LocalMesh board, covered in generated objects

About

Photographs to a textured mesh on one 8 GB NVIDIA card, offline. Built on TRELLIS.2, with a four view path, and the photographs' own pixels projected onto the texture so printed text stays readable.

Topics

Resources

Contributing

Stars

45 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages