Skip to content

Conversation

jeromeetienne
Copy link
Contributor

@jeromeetienne jeromeetienne commented Oct 6, 2025

This PR adds the texture + images

  • texture is able to handle 2d and 3d textures (i will prepare a PR for volume)
  • image is always facing the camera, it got a 3d position and an extent (as in matplotlib)

Notes

  • i motified the __init__.py to include the new files texture+image
  • the perspective computation of the extend may be wrong.. im not sure. i dont have the camera position in the image.render() function

@jeromeetienne
Copy link
Contributor Author

Screenshot 2025-10-06 at 11 44 42

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

Do you have some specs or notes on Texture and Image ?

@jeromeetienne
Copy link
Contributor Author

@rougier im not sure i understand what you mean...

Do you want a longer description for the PR ? something else ?

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

Texture can be actually 1D, 2D, 3D and the docstring on your texture objects implies it is 2D.
For the image, we need to decide what is the API. It can be planar but oriented in source (like the markers) and my question was whether you have such description somewhere. If not, we may need to discuss the API.

In the meantime I can merge if we want to test your implementation.

@rossant
Copy link
Member

rossant commented Oct 6, 2025

Indeed, in Datoviz, one must explicitly specify 1D, 2D, or 3D when creating a texture.

@jeromeetienne
Copy link
Contributor Author

@rougier Here the image is always facing the camera. what we call a sprite when i was doing 3d. even like that i dont think the perspective is correct.

@rossant i can create more specific class if needed.

Do you guys want the same visual 'image facing the camera' and 'oriented polygon with a texture' ? this seems a quite a different thing. what about we call this one sprite and the other image ?

PS: how to display a texture in matplotlib ? i have seen this https://github.com/rougier/tiny-renderer/blob/master/head.py .. it run directly on the np.ndarray and then update the whole image... do we want to do that ?

@jeromeetienne
Copy link
Contributor Author

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

I think Planar image + orthognal axis might be enough for image. This is used for example when projecting iamge on 3D cube. Such projection is not straighforward with matplotlib though. I will post my experimental code below.

@rossant
Copy link
Member

rossant commented Oct 6, 2025

Do you guys want the same visual 'image facing the camera' and 'oriented polygon with a texture' ? this seems a quite a different thing. what about we call this one sprite and the other image ?

You're right the two are quitte different. In Datoviz, Image is actually a sprite always facing camera by design. I don't have a specific visual for oriented textured quads at the moment, one has to use the textured Mesh visual with a quad.

@rossant
Copy link
Member

rossant commented Oct 6, 2025

I think Planar image + orthognal axis might be enough for image. This is used for example when projecting iamge on 3D cube. Such projection is not straighforward with matplotlib though. I will post my experimental code below.

So this is not a sprite as this image may not be facing camera, right?

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

# Package: Graphic Server Protocol / Matplotlib
# Authors: Nicolas P .Rougier <[email protected]>
# License: BSD 3 clause

import glm
import camera
import numpy as np
import imageio.v3 as iio
import matplotlib.pyplot as plt
from matplotlib.path import Path
import matplotlib.transforms as mtransforms
from matplotlib.patches import PathPatch
from matplotlib.collections import PolyCollection


def warp(T1, T2):
    """
    Return an affine transform that warp triangle T1 into triangle
    T2.

    Parameters
    ----------
    T1 : (3,2) np.ndarray
      Positions of the first triangle vertices
    T2 : (3,2) np.ndarray
      Positions of the first triangle vertices

    Raises
    ------

    `LinAlgError` if T1 or T2 are degenerated triangles
    """

    T1 = np.c_[np.array(T1), np.ones(3)]
    T2 = np.c_[np.array(T2), np.ones(3)]
    M = np.linalg.inv(T1) @ T2
    return mtransforms.Affine2D(M.T)

def textured_triangle(ax, T, UV, texture, intensity,
                      interpolation="none", image=None, zorder=0):
    """
    Parameters
    ----------
    T : (3,2) np.ndarray
      Positions of the triangle vertices
    UV : (3,2) np.ndarray
      UV coordinates of the triangle vertices
    texture:
      Image to use for texture
    """

    w,h = texture.shape[:2]
    Z = UV*(w,h)
    xmin, xmax = int(np.floor(Z[:,0].min())), int(np.ceil(Z[:,0].max()))
    ymin, ymax = int(np.floor(Z[:,1].min())), int(np.ceil(Z[:,1].max()))
    texture = (texture[ymin:ymax, xmin:xmax,:] * intensity).astype(np.uint8)
    extent = xmin/w, xmax/w, ymin/h, ymax/h
    transform = warp (UV,T) + ax.transData
    path =  Path([UV[0], UV[1], UV[2], UV[0]], closed=True)

    if image is not None:
        image.set_data(texture)
        image.set_extent(extent)
        image.set_transform(transform)
        image.set_clip_path((path,transform))
        image.patch.set_path(path)
        image.patch.set_transform(transform)
    else:
        image = ax.imshow(texture, interpolation=interpolation, origin='lower',
                          zorder=zorder,
                          extent=extent, transform=transform, clip_path=(path,transform))
        patch = PathPatch(path, facecolor="none", edgecolor=(0.0,0.0,0.0,0.5),
                          linewidth=0.25, transform=transform, zorder=zorder+1)
        image.patch = patch
        ax.add_patch(patch)

    return image


def obj_read(filename):
    """
    Read a wavefront filename and returns vertices, texcoords and
    respective indices for faces and texcoords
    """

    V, T, N, Vi, Ti, Ni = [], [], [], [], [], []
    with open(filename) as f:
       for line in f.readlines():
           if line.startswith('#'):
               continue
           values = line.split()
           if not values:
               continue
           if values[0] == 'v':
               V.append([float(x) for x in values[1:4]])
           elif values[0] == 'vt':
               T.append([float(x) for x in values[1:3]])
           elif values[0] == 'vn':
               N.append([float(x) for x in values[1:4]])
           elif values[0] == 'f' :
               Vi.append([int(indices.split('/')[0]) for indices in values[1:]])
               Ti.append([int(indices.split('/')[1]) for indices in values[1:]])
               Ni.append([int(indices.split('/')[2]) for indices in values[1:]])
    return np.array(V), np.array(T), np.array(Vi)-1, np.array(Ti)-1


from gsp import core, visual, transform, glm

canvas   = core.Canvas()
viewport = core.Viewport(canvas)
camera = camera.Camera("perspective", theta=10, phi=-5)

positions, texcoords, face_indices, texcoords_indices = obj_read("data/head.obj")
texture = iio.imread("data/uv-grid.png")[::-1,::1,:3]
images = []


def update(viewport=None, model=None, view=None, proj=None):

    transform = proj @ view @ model
    V = positions[face_indices]
    UV = texcoords[texcoords_indices]

    # Computer lighting on non-projected faces
    N = np.cross(V[:,2]-V[:,0], V[:,1]-V[:,0])
    N = N / np.linalg.norm(N,axis=1).reshape(len(N),1)
    L = np.dot(N, (0,0,-1))

    V = glm.to_vec3(glm.to_vec4(positions) @ transform.T)
    V = V[face_indices]
    I = np.argsort(-V[:,:,2].mean(axis=1))

    V = V[I][...,:2]
    UV = UV[I][...,:2]
    L = abs(L[I])

    ax = viewport._axes

    zorder = 10
    if not len(images):
        for v, uv, l in zip(V, UV, L):

            if l > 0:
                try:
                    image = textured_triangle(ax, v, uv, texture, (l+1)/2, "none", None, zorder)
                    images.append(image)
                except np.linalg.LinAlgError:
                    pass
            zorder += 2
    else:
        for v, uv, l, image in zip(V, UV, L, images):
            if l > 0:
                try:
                    image = textured_triangle(ax, v, uv, texture, (l+1)/2, "none", image, zorder)
                except np.linalg.LinAlgError:
                    pass
            zorder += 2



camera.connect(viewport, "motion",  update)
update(viewport, camera.model, camera.view, camera.proj)

# plt.savefig("head-camera.pdf")
plt.show()

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

@cyrille Yes, the image will face camera if no axis is given and is oriented in space when an axis is given. This is what I ddi for markers.

@rossant
Copy link
Member

rossant commented Oct 6, 2025

@cyrille Yes, the image will face camera if no axis is given and is oriented in space when an axis is given. This is what I ddi for markers.

Okay. Perhaps this is something Datoviz could support in the Image visual, I imagine it could be relatively easy (just rotating the quad vertices in 3D).

@jeromeetienne
Copy link
Contributor Author

@rougier can you commit a working version of that somewhere ?

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

Not easily, it is wit a previous experimental code. The important function is the textured triangle function that shoud lwork as expected. And yes, it is slow.

@jeromeetienne
Copy link
Contributor Author

@rougier can you run this code ?

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

@jeromeetienne
Copy link
Contributor Author

@rougier thanks

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

Note that we cannor avoid the thin line around triangles because of antialiasing that cannot be removed when a clip mask is used.

@jeromeetienne
Copy link
Contributor Author

Proposal on naming

  • Image remains the image always facing the camera
  • image which are orientated in 3d are created are basically a mesh.
    • They share strictly the same code, the example pasted by @rougier show it
    • by Mesh.fromImageQuad(imagePath) <- or similar

@rossant
Copy link
Member

rossant commented Oct 6, 2025

In Datoviz, I will probably use the same Image visual for both, as it is just a matter of adding a couple of arguments to the existing Image visual (axis and possible rotation angle around it). In the Datoviz GSP renderer, I should therefore have the information whether I am receiving a camera-facing image, or 3D image, or a generic mesh. With your proposal, would I be able to know which of these three cases I'm in?

@rougier
Copy link
Contributor

rougier commented Oct 6, 2025

I would prefer a (planar) Image visual with an axis option for orientation.This already exists for Markers. Now, concerning the implementation, it is actually a Mesh but Images are so common in sciviz it might be worth to have a dedicated visual.

@rossant
Copy link
Member

rossant commented Oct 6, 2025

The image shader is certainly much simpler than the mesh shader, which supports lighting etc. So yes, it would make sense to have a separate visual for the mesh.

And an Image visual with an optional axis vector would work, I think.

@rougier do we also need an angle argument in addition to the axis?

@jeromeetienne
Copy link
Contributor Author

jeromeetienne commented Oct 6, 2025

In Datoviz, I will probably use the same Image visual for both, as it is just a matter of adding a couple of arguments to the existing Image visual (axis and possible rotation angle around it). In the Datoviz GSP renderer, I should therefore have the information whether I am receiving a camera-facing image, or 3D image, or a generic mesh. With your proposal, would I be able to know which of these three cases I'm in?

for the renderer point of view, it will be either camera-facing image or a mesh. there is no such thing as a 3d image (quad plane orientated in 3d).

  • the code to display a 3d image is exactly the same as the one to display a mesh.
  • no need to duplicate the code
  • duplicating the code would lead to more bug, and less maintenability.

@jeromeetienne
Copy link
Contributor Author

I would prefer a (planar) Image visual with an axis option for orientation.This already exists for Markers. Now, concerning the implementation, it is actually a Mesh but Images are so common in sciviz it might be worth to have a dedicated visual.

so you suggest to have twice the code of mesh ? like once in image, and once in mesh

on that i will quote rfc 1925 😃 a very good read

It is always possible to aglutenate multiple separate problems into a single complex interdependent solution. In most cases this is a bad idea.

Another quote from unix philosophy : "do one thing and do it well"

@rougier
Copy link
Contributor

rougier commented Oct 7, 2025

Still, Images & Meshes are not exactly the same since.

Copy link
Contributor Author

@jeromeetienne jeromeetienne left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rougier i dont get it, willing to show how to display an image in 3d on matplotlib, you provided a mesh display.

isn't that a hint that those 2 code are similar ?

@rossant
Copy link
Member

rossant commented Oct 7, 2025

The code may be the same in matplotlib, but not in Datoviz, so separating the two in the protocol may make sense? Is it possible to use the same code path in the matplotlib GSP renderer, for the two different visual abstractions?

I agree code should not be duplicated! But I think it's possible to avoid duplication while keeping a "virtual" distinction at the protocol spec level.

@rougier
Copy link
Contributor

rougier commented Oct 7, 2025

The mesh / image code will be approximately the same for matplotlib but this is not a good reason to not have a Image and a Mesh for GSP. At the extreme point, everything can be rendered with only triangles and still, we offer higher level API. Image is such an example because it is pervasive in sciviz. The way it is implemented is another story. In matplotlib, you could use the Mesh visual under the hood.

Copy link
Contributor Author

@jeromeetienne jeromeetienne left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes we can aglutenate multiple separate problems into a single complex interdependent solution. it is possible to do so.

It will result is hard to maintain code, and will be error prone.

i will do it, please provide a clear definition of the API you want.

@rossant
Copy link
Member

rossant commented Oct 7, 2025

Well, on the contrary, I think it is better to split the complex mesh visual into multiple separate problems, the generic mesh, and the specific orientable image in 3D.

If you prefer, we could also split the Image visual in two:

  • camera facing 2D image
  • orientable image in 3D with an axis

That would make three different visuals at the API level:

  • camera facing 2D image
  • orientable image in 3D
  • mesh

@rougier @jeromeetienne what do you think?

@rougier
Copy link
Contributor

rougier commented Oct 7, 2025

Maybe we should start by defining the API, independently of implementation.

Copy link
Contributor Author

@jeromeetienne jeromeetienne left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's have some user stories too, thus we can justify the tech choices

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants