Surface normals

Surface-normal estimation predicts the direction each visible surface faces. LibreYOLO exposes it as the normal task, which returns a dense field of unit vectors on the original image canvas.

Definition

The normal task predicts a three-component unit vector per pixel from a single RGB image: the direction the surface at that pixel faces. Unlike depth, the output has no free scale, so two predictions are directly comparable without alignment.

A prediction fills result.normal_map, a NormalMap payload holding an (H, W, 3) float32 array on the original image canvas, also reachable as result.normals. Vectors use LibreYOLO's OpenCV camera frame, with +x right, +y down and +z into the scene, and they face the camera, so a fronto-parallel surface reads (0, 0, -1). .assert_normalized() checks that every pixel is finite and unit length within a tolerance. result.boxes stays empty, so conf, iou and max_det have no effect, and Results.plot() covers this task.

Models

Two families serve normal.

MoGe-2 is the dedicated one: a single-forward monocular geometry model in three encoder sizes. LibreYOLO does not copy these checkpoints into its own organization; loading one downloads the matching size from the official repositories at a pinned revision and verifies it against a recorded SHA-256.

LibreMODUS produces normals as one target of an any-to-any model, and can take a depth map rather than an RGB image as its input. It needs the modus extra and your own authenticated Hugging Face account, and it offers neither val() nor export(), so it does not take part in the validation and export sections below.

Predict

MoGe-2 weights download on first use and are cached locally.

Predict a normal field
from libreyolo import LibreYOLO, SAMPLE_IMAGE model = LibreYOLO("LibreMoGe2s-normal.pt")result = model(SAMPLE_IMAGE, save=True) normals = result.normal_mapprint(normals.data.shape)      # (H, W, 3) float32 unit vectorsnormals.assert_normalized()    # raises if any pixel is not unit length
Read one pixel
from libreyolo import LibreYOLO, SAMPLE_IMAGE model = LibreYOLO("LibreMoGe2s-normal.pt")result = model(SAMPLE_IMAGE) # OpenCV camera frame: +x right, +y down, +z into the scene. A surface# facing the camera reads close to (0, 0, -1).field = result.normals.datah, w = field.shape[:2]print(field[h // 2, w // 2])
Save the visualization
from libreyolo import LibreYOLO, SAMPLE_IMAGE model = LibreYOLO("LibreMoGe2s-normal.pt")result = model(SAMPLE_IMAGE) # plot() renders the field; it is defined for normal and edge results.result.plot().save("normals.png")

imgsz must be divisible by the ViT encoder's patch size, which LibreYOLO checks before the run starts. Predicting a list of images runs one forward pass per image; this task has no stacked-batch fast path. See prediction for sources, streaming and result handling.

Dataset format

Normal validation pairs each image with a same-stem three-channel 16-bit PNG of the same resolution, plus an optional validity mask.

dataset/
  data.yaml
  images/
    val/room.jpg
  normals/
    val/room.png
  masks/
    val/room.png
yaml
path: dataset
train: images/train
val: images/val
normals_dir: normals
masks_dir: masks
nc: 1
names: {0: normal}

The target PNG is exactly three-channel uint16 with channels stored as RGB. Decoding is n = png / 65535 * 2 - 1 followed by renormalizing each vector, and the decoded vectors use the same OpenCV camera frame as the predictions. A mask pixel counts as valid when nonzero; without a mask file, every finite nonzero decoded vector is valid. Invalid and padded target pixels are held internally as (0, 0, 0) and never contribute to a metric. See dataset formats for the full contract.

Train

Neither normal family has a training implementation: train() raises NotImplementedError on both. MoGe-2's page points at its pinned official checkpoints for predict, validate and export.

Validate

val() measures the angle between each predicted vector and its ground-truth vector, over the pixels the dataset marks valid.

Validate and read the metric keys
from libreyolo import LibreYOLO model = LibreYOLO("LibreMoGe2s-normal.pt")metrics = model.val(data="my-dataset.yaml", imgsz=518) print(metrics["metrics/mean_angular_error"])     # degreesprint(metrics["metrics/median_angular_error"])   # degreesprint(metrics["metrics/within_11_25"])           # percent of pixelsprint(metrics["metrics/within_22_5"], metrics["metrics/within_30"])

metrics/mean_angular_error and metrics/median_angular_error are that angle in degrees, and lower is better. metrics/within_11_25, metrics/within_22_5 and metrics/within_30 are the percentage of valid pixels whose angular error falls within 11.25, 22.5 and 30 degrees, so higher is better. Note the unit: those three are percentages, not fractions. fitness is metrics/within_11_25 divided by 100, which puts best-checkpoint selection on the same [0, 1] scale as every other task.

Export

An exported normal model loads back through LibreYOLO() on its file suffix, so a .onnx file behaves like a checkpoint and returns the same Results.

Export
from libreyolo import LibreYOLO model = LibreYOLO("LibreMoGe2s-normal.pt")model.export(format="onnx", imgsz=518)
Run the exported file
from libreyolo import LibreYOLO, SAMPLE_IMAGE # The factory routes on the file suffix, so an exported artifact loads# like any checkpoint and returns the same Results object.model = LibreYOLO("LibreMoGe2s-normal.onnx")result = model(SAMPLE_IMAGE) print(result.normal_map.data.shape)

Normal export uses a fixed-resolution, batch-1 runtime contract: dynamic and a batch other than 1 are rejected, and imgsz must be divisible by the encoder's patch size. Per-format coverage is on the MoGe-2 page and in the full export matrix. Export lists the arguments every format accepts.

Verified against LibreYOLO v1.5.0.