5
$\begingroup$

I'm trying to find the total UV area used relative to the image bounds - i.e. a percentage/ratio of used space vs. available space.

I know you can use sum(f.calc_area() for f in bm.faces) for a bmesh, but I'm unsure how to do this for the UV data instead of the actual 3D mesh data.

This UV map should give an output of 1.0:

filled UV map

While this UV map should give an output of 0.25:

partial UV map

The purpose of this question is to determine (and fix) the accuracy of image textures of known dimensions when applied to surfaces.

E.g. if we know a particular brick texture should be 2 meters wide, we should be able to use the mesh surface area in combination with the UV area utilized to calculate a multiplier to scale the texture up or down to be the correct size.

Some edge cases I'm not sure how to handle:

  • Overlapping UVs
  • UVs outside the image area
$\endgroup$
4
  • $\begingroup$ So if i understand this correctly you want a formula for the percentage of the Image used by the UV. If your overall image has a bound of 1, and your downscalled UV (1/2) uses 25% this would mean that you have a ratio of 1/4 or am i wrong? 100% = 1 (Full image bound) 1/4 = 25% so wouldn't it be possible to use this formula? meaning if you want 20% 0.2 (20%) = 1/X 1/0.2 = 5 Meaning: if you want 20% of the Image used as a UV you have to divide by 5 $\endgroup$ Commented Nov 21, 2022 at 10:18
  • $\begingroup$ Exactly, but the UV in practice is not downscaled, this is just an example. Most UVs try to fill all the available area, but there are gaps between the islands that are empty space. I need to calculate what the actual UV area used is, relative to the total area available, in any arbitrary space/mesh. $\endgroup$
    – Greg Zaal
    Commented Nov 21, 2022 at 11:35
  • $\begingroup$ If the uv faces overlap, for example uv projection, what result do you want? Is there an exception for such cases? $\endgroup$
    – tetii
    Commented Nov 22, 2022 at 4:43
  • $\begingroup$ It seems to me this is an XY problem: if you want consistent scale, you just want to define image dimensions, e.g. define UV dimensions as 0..1 meters. Now you don't need to calculate an area, because you still want to maintain proportions, so all you need to do is to compare an edge length with a loop length. Still, the easiest method to calculate the UV area seems to be triangulating the mesh, and summing up all UV triangles areas… $\endgroup$ Commented Nov 25, 2022 at 10:22

3 Answers 3

2
+200
$\begingroup$

I thought I'd start with a basic solution, without the edge cases. So a script v0.1, so to say. Perhaps the brilliant minds in the community can elaborate on it, coming to a final solution together...

Since you mentioned bmesh in your question, I used it here.

This script loops over the faces of a bmesh, gets the uv coordinates of each vertex of the face and calculates the area of this uv triangle or quad.

import bpy
import bmesh
import mathutils

'''
Assumptions for script v0.1
---------------------------
1. UV map doesn't have overlapping faces
2. All UV coordinates are between 0.0 and 1.0
'''

# Force object mode, to ensure uv map is up to date
obj_mode = bpy.context.active_object.mode
if obj_mode != 'OBJECT':
    bpy.ops.object.mode_set(mode='OBJECT')

# Get active object as bmesh
bm = bmesh.new()
bm.from_mesh(bpy.context.active_object.data)

# Get active uv map
uv_layer = bm.loops.layers.uv.active

# Init uv area
uv_area = 0

# Iterate mesh faces
for face in bm.faces:
    # Get uv vertices
    uv_verts = []
    for loop in face.loops:
        uv_verts.append(loop[uv_layer].uv)
    
    # Calculate area between uv vertices
    if len(uv_verts) >= 3:
        # First triangle of quad
        uv_area += mathutils.geometry.area_tri(uv_verts[0], uv_verts[1], uv_verts[2])
    if len(uv_verts) == 4:
        # Second triangle of quad
        uv_area += mathutils.geometry.area_tri(uv_verts[0], uv_verts[2], uv_verts[3])
    
    # I suppose this can never happen...
    if len(uv_verts) > 4:
        print('N-gons in UV map not supported!')
        break

# Print uv coverage, equal to the calculated uv area sum,
# since we assume no overlaps and uv coords between 0.0 and 1.0.
print('UV coverage:', uv_area)

# Return to original mode
if obj_mode != 'OBJECT':
    bpy.ops.object.mode_set(mode=obj_mode)
$\endgroup$
2
$\begingroup$

I had an additional idea, for handling overlapping UV's. The approach is different, so I post it as a second answer.

The idea is: draw the faces of the UV map in an image and count the drawn pixels to calculate the coverage. enter image description here

The advantage is that overlapping parts in the UV map merge together in the image, so the calculated coverage will never be greater than 100%.

Drawing the image can be done with the gpu module. In particular the offscreen rendering comes in handy here.

The code:

import bpy
import bmesh
import gpu
from gpu_extras.batch import batch_for_shader
from mathutils import Matrix, Vector
import numpy as np
from math import sqrt

'''
Calculate UV coverage for mesh - script v0.2
--------------------------------------------
UV areas outside 0.0-1.0 will be ignored.

Method: draw the UV faces in a temporary image (an offscreen gpu buffer),
similar to the image you see in the UV editor,
and count the number of drawn pixels to get the UV coverage.

This way overlapping UV will not result in a coverage > 100%.
'''

# Size of offscreen rendered UV image
UV_IMG_SIZE = 512

# Force object mode, to ensure uv map is up to date
obj_mode = bpy.context.active_object.mode
if obj_mode != 'OBJECT':
    bpy.ops.object.mode_set(mode='OBJECT')

# Get active object as bmesh
bm = bmesh.new()
bm.from_mesh(bpy.context.active_object.data)

# Get active uv map
uv_layer = bm.loops.layers.uv.active

# Init offscreen gpu image of UV map
# See: https://docs.blender.org/api/current/gpu.html#copy-offscreen-rendering-result-back-to-ram
# and: https://docs.blender.org/api/current/gpu.html#d-rectangle
offscreen = gpu.types.GPUOffScreen(UV_IMG_SIZE, UV_IMG_SIZE)
shader = gpu.shader.from_builtin('2D_UNIFORM_COLOR')
shader.bind()
shader.uniform_float('color', (0.5, 0.5, 0.5, 1.0))

# Draw uv image in offscreen gpu buffer
with offscreen.bind():
    fb = gpu.state.active_framebuffer_get()
    fb.clear(color=(0.0, 0.0, 0.0, 0.0))
    with gpu.matrix.push_pop():
        # Set gpu draw matrix to [-1.0, 1.0]
        gpu.matrix.load_matrix(Matrix.Identity(4))
        gpu.matrix.load_projection_matrix(Matrix.Identity(4))
        vec_one = Vector((1, 1))

        # Iterate mesh faces
        for face in bm.faces:
            # Get uv vertices
            uv_verts = []
            for loop in face.loops:
                # Convert vector from [0.0, 1.0] to [-1.0, 1.0]
                uv_verts.append((loop[uv_layer].uv) * 2 - vec_one)
            
            # Draw uv triangles in offscreen image
            if len(uv_verts) >= 3:
                # First triangle
                vertices = (uv_verts[0], uv_verts[1], uv_verts[2])
                batch = batch_for_shader(shader, 'TRIS', {'pos': vertices})
                batch.draw(shader)
            if len(uv_verts) == 4:
                # Second triangle
                vertices = (uv_verts[0], uv_verts[2], uv_verts[3])
                batch = batch_for_shader(shader, 'TRIS', {'pos': vertices})
                batch.draw(shader)
            
            # Only triangles and quads are supported
            if len(uv_verts) > 4:
                print('N-gons in UV map not supported!')
                break
    
    # Get RGBA color data from offscreen buffer
    buffer = fb.read_color(0, 0, UV_IMG_SIZE, UV_IMG_SIZE, 4, 0, 'FLOAT')
    buffer.dimensions = UV_IMG_SIZE * UV_IMG_SIZE * 4

# Release offscreen render
offscreen.free()

# DEBUG: create image in Blender to inspect the result
if False:
    IMAGE_NAME = 'UV Coverage'
    if IMAGE_NAME not in bpy.data.images:
        bpy.data.images.new(IMAGE_NAME, UV_IMG_SIZE, UV_IMG_SIZE)
    image = bpy.data.images[IMAGE_NAME]
    image.scale(UV_IMG_SIZE, UV_IMG_SIZE)
    image.pixels.foreach_set(buffer)

# From RGBA color data, get the alpha channel (in numpy array)
buffer_np = np.array(buffer).reshape((UV_IMG_SIZE, UV_IMG_SIZE, 4))[:, :, 3]

# Calculate uv coverage:
# filled area (alpha is not zero) divided by total image size
uv_coverage = buffer_np[buffer_np != 0].shape[0] / (UV_IMG_SIZE * UV_IMG_SIZE)

# Print uv coverage
print('UV coverage:', uv_coverage)

# Return to original mode
if obj_mode != 'OBJECT':
    bpy.ops.object.mode_set(mode=obj_mode)
$\endgroup$
1
  • $\begingroup$ That's a really creative solution! I'm not sure yet if it's the right way to handle this edge case for me, but definitely a great idea and a useful tool for the future! $\endgroup$
    – Greg Zaal
    Commented Nov 28, 2022 at 6:16
1
$\begingroup$

Geonodes

Just for fun, I'll understand if you downvote this, here's a geonodes solution:

You can use Python to add the geonodes modifier (and even to create the node tree from scratch), and to evaluate the object with the modifier and then read the attribute value. Perhaps it would even make sense for some dense topology, where the C++ implementation of the geonodes would prove to be more efficient than Python…

Edge Cases

In order to take care of the overlap, the complexity of the node tree explodes:

You might think dealing with UV outside of "UV area" is as easy as clamping vertex coordinate to 0..1, but that's not true:

As you move beyond 0..1 range, the shape inside keeps changing and you would expect an area to also change, however the mesh used for calculating the area doesn't change, because the vertex marked red keeps being clamped to the same spot. In order to make a proper calculation, the quad would have to be converted to an ngon, using two snapped vertices, marked with green and blue circles:

The solution is to use another boolean, to intersect with a cuboid taking all possible vertical (Z axis) space, and 0..1 on X and Y:

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .