Merge pull request #29729 from SavyaSanchi-Sharma:vizsplat

Added Gaussian Splat Renderer Support - #29729

Adds 3D Gaussian Splatting support to `ptcloud`: a loader for trained scenes and a `viz3d` renderer that draws them alpha-blended and depth-sorted alongside existing opaque geometry.

### Result

<img width="1842" height="811" alt="splat" src="https://github.com/user-attachments/assets/abfa5c01-6dbb-4367-b426-a618e3748649" />

[.splat source](https://huggingface.co/cakewalk/splat-data/resolve/main/train.splat)

### Pull Request Readiness Checklist

See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request

- [x] I agree to contribute to the project under Apache 2 License.
- [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV
- [x] The PR is proposed to the proper branch
- [ ] There is a reference to the original bug report and related work
- [x] There is accuracy test, performance test and test data in opencv_extra repository, if applicable
      Patch to opencv_extra has the same branch name.
- [x] The feature is well documented and sample code can be built with the project CMake
This commit is contained in:
Savya Sanchi Sharma
2026-10-12 19:03:54 +05:30
committed by GitHub
parent 7510afe588
commit 303f83ef92
16 changed files with 1787 additions and 1 deletions

View File

@@ -86,7 +86,8 @@ public:
ARRAY_BUFFER = 0x8892, //!< The buffer will be used as a source for vertex data
ELEMENT_ARRAY_BUFFER = 0x8893, //!< The buffer will be used for indices (in glDrawElements, for example)
PIXEL_PACK_BUFFER = 0x88EB, //!< The buffer will be used for reading from OpenGL textures
PIXEL_UNPACK_BUFFER = 0x88EC //!< The buffer will be used for writing to OpenGL textures
PIXEL_UNPACK_BUFFER = 0x88EC, //!< The buffer will be used for writing to OpenGL textures
TEXTURE_BUFFER = 0x8C2A //!< The buffer will be sampled by a shader through ogl::TextureBuffer
};
enum Access
@@ -399,6 +400,102 @@ private:
Format format_;
};
/** @brief Smart pointer for an OpenGL buffer texture with reference counting.
A buffer texture is a read-only, one-dimensional view of an ogl::Buffer that a shader samples with
`samplerBuffer` (float formats) or `isamplerBuffer` (integer formats). Unlike ogl::Texture2D it has
no filtering, no mipmaps and no normalized coordinates: the shader indexes it by element with
`texelFetch`. It is the way to feed a shader more data than uniforms can hold.
The object keeps a reference to the source buffer, so the buffer stays alive as long as the texture
does. The attachment records the buffer's id at create() time, so re-creating or resizing the source
buffer needs a matching create() call to re-attach.
Requires OpenGL 3.1 or later.
*/
class CV_EXPORTS TextureBuffer
{
public:
/** @brief The internal format the shader sees, i.e. component count and type per element.
*/
enum Format
{
NONE = 0,
R32F = 0x822E, //!< 1 x 32-bit float
RG32F = 0x8230, //!< 2 x 32-bit float
RGBA32F = 0x8814, //!< 4 x 32-bit float
R32I = 0x8235, //!< 1 x 32-bit signed int
RG32I = 0x823B, //!< 2 x 32-bit signed int
RGBA32I = 0x8D82 //!< 4 x 32-bit signed int
};
/** @brief The constructors.
Creates an empty ogl::TextureBuffer object or attaches one to an existing buffer.
*/
TextureBuffer();
/** @overload
@param buf Source buffer, must be non-empty. Should have been created with the
ogl::Buffer::TEXTURE_BUFFER target.
@param aformat Element format. See cv::ogl::TextureBuffer::Format .
@param autoRelease Auto release mode (if true, release will be called in object's destructor).
*/
TextureBuffer(const Buffer& buf, Format aformat, bool autoRelease = false);
/** @brief Attaches the texture to an OpenGL buffer.
@param buf Source buffer, must be non-empty. Should have been created with the
ogl::Buffer::TEXTURE_BUFFER target.
@param aformat Element format, must not be NONE. See cv::ogl::TextureBuffer::Format .
@param autoRelease Auto release mode (if true, release will be called in object's destructor).
No data is copied - the texture reads whatever the buffer holds at draw time, so writing to the
buffer is enough to update what the shader samples.
*/
void create(const Buffer& buf, Format aformat, bool autoRelease = false);
/** @brief Detaches from the buffer and destroys the texture object if needed.
The function will call setAutoRelease(true) .
*/
void release();
/** @brief Sets auto release mode.
@param flag Auto release mode (if true, release will be called in object's destructor).
The lifetime of the OpenGL object is tied to the lifetime of the context, so ogl::TextureBuffer
doesn't destroy the OpenGL object in its destructor by default. See
ogl::Texture2D::setAutoRelease for the full rationale.
*/
void setAutoRelease(bool flag);
/** @brief Binds the texture to a texture unit for the GL_TEXTURE_BUFFER target.
@param unit Texture unit index to bind to, must be non-negative. Pass the same index to the
shader's sampler uniform.
Leaves that unit active, so bind the unit a caller expects to stay current last.
*/
void bind(int unit = 0) const;
//! element format, NONE if not attached
Format format() const;
//! true if the texture is not attached to any buffer
bool empty() const;
//! get OpenGL object id
unsigned int texId() const;
class Impl;
private:
Ptr<Impl> impl_;
Buffer buf_;
Format format_;
};
/** @brief Wrapper for OpenGL Client-Side Vertex arrays.
ogl::Arrays stores vertex data in ogl::Buffer objects.
@@ -695,12 +792,36 @@ public:
*/
static void setUniformVec3(int location, Vec3f vec);
/** @brief Sets a uniform vector value.
@param location Uniform location.
@param vec Vector value.
*/
static void setUniformVec2(int location, Vec2f vec);
/** @brief Sets a uniform vector value.
@param location Uniform location.
@param vec Vector value.
*/
static void setUniformVec4(int location, Vec4f vec);
/** @brief Sets a uniform matrix value.
@param location Uniform location.
@param mat Matrix value.
*/
static void setUniformMat4x4(int location, Matx44f mat);
/** @brief Sets a uniform scalar value.
@param location Uniform location.
@param value Float value.
*/
static void setUniform1f(int location, float value);
/** @brief Sets a uniform scalar value.
@param location Uniform location.
@param value Integer value. Also used for sampler uniforms, where it is the texture unit index.
*/
static void setUniform1i(int location, int value);
//! get OpenGL object id
unsigned int programId() const;
@@ -736,6 +857,22 @@ enum IndexType {
//! capability
enum Capability {
DEPTH_TEST = 0x0B71,
CULL_FACE = 0x0B44,
BLEND = 0x0BE2,
};
//! blend factor
enum BlendFactor {
BLEND_ZERO = 0,
BLEND_ONE = 1,
BLEND_SRC_COLOR = 0x0300,
BLEND_ONE_MINUS_SRC_COLOR = 0x0301,
BLEND_SRC_ALPHA = 0x0302,
BLEND_ONE_MINUS_SRC_ALPHA = 0x0303,
BLEND_DST_ALPHA = 0x0304,
BLEND_ONE_MINUS_DST_ALPHA = 0x0305,
BLEND_DST_COLOR = 0x0306,
BLEND_ONE_MINUS_DST_COLOR = 0x0307,
};
/** @brief Render OpenGL texture or primitives.
@@ -787,6 +924,26 @@ CV_EXPORTS void drawArrays(int first, int count, int mode);
*/
CV_EXPORTS void drawElements(int first, int count, int type, int mode);
/** @brief Renders multiple instances of primitives from array data with index buffer set.
@param first Byte offset of the first index in the bound element buffer.
@param count Number of indices to draw.
@param type Index type. See cv::ogl::IndexType.
@param mode Rendering mode. See cv::ogl::RenderModes.
@param instanceCount Number of instances to draw. Each instance sees its own gl_InstanceID.
*/
CV_EXPORTS void drawElementsInstanced(int first, int count, int type, int mode, int instanceCount);
/** @brief Sets the blend function for the current target.
@param sfactor Source blend factor. See cv::ogl::BlendFactor.
@param dfactor Destination blend factor. See cv::ogl::BlendFactor.
*/
CV_EXPORTS void blendFunc(int sfactor, int dfactor);
/** @brief Enables or disables writing to the depth buffer.
@param flag If false, depth testing still happens but no depth value is written.
*/
CV_EXPORTS void depthMask(bool flag);
/** @brief Enable server-side GL capabilities.
@param cap The capability to enable.
*/

View File

@@ -1253,6 +1253,172 @@ unsigned int cv::ogl::Texture2D::texId() const
}
//////////////////////////////////////////////////////////////////////////////////////////
// ogl::TextureBuffer
#ifndef HAVE_OPENGL
class cv::ogl::TextureBuffer::Impl
{
};
#else
class cv::ogl::TextureBuffer::Impl
{
public:
static const Ptr<Impl> empty();
Impl();
Impl(GLenum internalFormat, GLuint bufId, bool autoRelease);
~Impl();
void bind(GLint unit) const;
void setAutoRelease(bool flag) { autoRelease_ = flag; }
GLuint texId() const { return texId_; }
private:
GLuint texId_;
bool autoRelease_;
};
const Ptr<cv::ogl::TextureBuffer::Impl> cv::ogl::TextureBuffer::Impl::empty()
{
static Ptr<Impl> p = makePtr<Impl>();
return p;
}
cv::ogl::TextureBuffer::Impl::Impl() : texId_(0), autoRelease_(false)
{
}
cv::ogl::TextureBuffer::Impl::Impl(GLenum internalFormat, GLuint bufId, bool autoRelease) : texId_(0), autoRelease_(autoRelease)
{
gl::GenTextures(1, &texId_);
CV_CheckGlError();
CV_Assert(texId_ != 0);
gl::BindTexture(gl::TEXTURE_BUFFER, texId_);
CV_CheckGlError();
gl::TexBuffer(gl::TEXTURE_BUFFER, internalFormat, bufId);
CV_CheckGlError();
gl::BindTexture(gl::TEXTURE_BUFFER, 0);
CV_CheckGlError();
}
cv::ogl::TextureBuffer::Impl::~Impl()
{
if (autoRelease_ && texId_)
gl::DeleteTextures(1, &texId_);
}
void cv::ogl::TextureBuffer::Impl::bind(GLint unit) const
{
gl::ActiveTexture(gl::TEXTURE0 + unit);
CV_CheckGlError();
gl::BindTexture(gl::TEXTURE_BUFFER, texId_);
CV_CheckGlError();
}
#endif // HAVE_OPENGL
cv::ogl::TextureBuffer::TextureBuffer() : format_(NONE)
{
#ifndef HAVE_OPENGL
throw_no_ogl();
#else
impl_ = Impl::empty();
#endif
}
cv::ogl::TextureBuffer::TextureBuffer(const Buffer& buf, Format aformat, bool autoRelease) : format_(NONE)
{
#ifndef HAVE_OPENGL
CV_UNUSED(buf);
CV_UNUSED(aformat);
CV_UNUSED(autoRelease);
throw_no_ogl();
#else
impl_ = Impl::empty();
create(buf, aformat, autoRelease);
#endif
}
void cv::ogl::TextureBuffer::create(const Buffer& buf, Format aformat, bool autoRelease)
{
#ifndef HAVE_OPENGL
CV_UNUSED(buf);
CV_UNUSED(aformat);
CV_UNUSED(autoRelease);
throw_no_ogl();
#else
CV_Assert( !buf.empty() );
CV_Assert( aformat != NONE );
impl_ = makePtr<Impl>(aformat, buf.bufId(), autoRelease);
buf_ = buf;
format_ = aformat;
#endif
}
void cv::ogl::TextureBuffer::release()
{
#ifdef HAVE_OPENGL
if (impl_)
impl_->setAutoRelease(true);
impl_ = Impl::empty();
#endif
buf_ = Buffer();
format_ = NONE;
}
void cv::ogl::TextureBuffer::setAutoRelease(bool flag)
{
#ifndef HAVE_OPENGL
CV_UNUSED(flag);
throw_no_ogl();
#else
impl_->setAutoRelease(flag);
#endif
}
void cv::ogl::TextureBuffer::bind(int unit) const
{
#ifndef HAVE_OPENGL
CV_UNUSED(unit);
throw_no_ogl();
#else
CV_Assert( unit >= 0 );
impl_->bind(unit);
#endif
}
cv::ogl::TextureBuffer::Format cv::ogl::TextureBuffer::format() const
{
return format_;
}
bool cv::ogl::TextureBuffer::empty() const
{
return format_ == NONE;
}
unsigned int cv::ogl::TextureBuffer::texId() const
{
#ifndef HAVE_OPENGL
throw_no_ogl();
#else
return impl_->texId();
#endif
}
////////////////////////////////////////////////////////////////////////
// ogl::Arrays
@@ -1959,6 +2125,26 @@ void cv::ogl::Program::setUniformVec3(int loc, Vec3f vec)
#endif
}
void cv::ogl::Program::setUniformVec2(int loc, Vec2f vec)
{
#ifndef HAVE_OPENGL
CV_UNUSED(loc); CV_UNUSED(vec);
throw_no_ogl();
#else
gl::Uniform2f(loc, vec(0), vec(1));
#endif
}
void cv::ogl::Program::setUniformVec4(int loc, Vec4f vec)
{
#ifndef HAVE_OPENGL
CV_UNUSED(loc); CV_UNUSED(vec);
throw_no_ogl();
#else
gl::Uniform4f(loc, vec(0), vec(1), vec(2), vec(3));
#endif
}
void cv::ogl::Program::setUniformMat4x4(int loc, Matx44f mat)
{
#ifndef HAVE_OPENGL
@@ -1969,6 +2155,26 @@ void cv::ogl::Program::setUniformMat4x4(int loc, Matx44f mat)
#endif
}
void cv::ogl::Program::setUniform1f(int loc, float value)
{
#ifndef HAVE_OPENGL
CV_UNUSED(loc); CV_UNUSED(value);
throw_no_ogl();
#else
gl::Uniform1f(loc, value);
#endif
}
void cv::ogl::Program::setUniform1i(int loc, int value)
{
#ifndef HAVE_OPENGL
CV_UNUSED(loc); CV_UNUSED(value);
throw_no_ogl();
#else
gl::Uniform1i(loc, value);
#endif
}
unsigned int cv::ogl::Program::programId() const
{
#ifndef HAVE_OPENGL
@@ -2187,6 +2393,37 @@ void cv::ogl::drawElements(int first, int count, int type, int mode)
#endif
}
void cv::ogl::drawElementsInstanced(int first, int count, int type, int mode, int instanceCount)
{
#ifndef HAVE_OPENGL
CV_UNUSED(first); CV_UNUSED(count); CV_UNUSED(type); CV_UNUSED(mode); CV_UNUSED(instanceCount);
throw_no_ogl();
#else
gl::DrawElementsInstanced(mode, count, type, reinterpret_cast<const void*>(static_cast<size_t>(first)), instanceCount);
CV_CheckGlError();
#endif
}
void cv::ogl::blendFunc(int sfactor, int dfactor)
{
#ifndef HAVE_OPENGL
CV_UNUSED(sfactor); CV_UNUSED(dfactor);
throw_no_ogl();
#else
gl::BlendFunc(sfactor, dfactor);
#endif
}
void cv::ogl::depthMask(bool flag)
{
#ifndef HAVE_OPENGL
CV_UNUSED(flag);
throw_no_ogl();
#else
gl::DepthMask(flag ? gl::TRUE_ : gl::FALSE_);
#endif
}
void cv::ogl::enable(int cap)
{
#ifndef HAVE_OPENGL

View File

@@ -911,6 +911,11 @@ namespace
CV_Error(cv::Error::OpenGlApiCallError, "OpenGL context is not initialized");
return FALSE;
}
const int scale = gtk_widget_get_scale_factor(GTK_WIDGET(area));
glViewport(0, 0,
gtk_widget_get_allocated_width(GTK_WIDGET(area)) * scale,
gtk_widget_get_allocated_height(GTK_WIDGET(area)) * scale);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
if(window->glDrawCallback) {
window->glDrawCallback(window->glDrawData);
}

View File

@@ -173,6 +173,72 @@ CV_EXPORTS_W void orientedBoundingBox3D(InputArray inputCloud, OutputArray cente
*/
CV_EXPORTS_W double approxEnclosingSphere3D(InputArray inputCloud, OutputArray center);
/** @brief Column layout of a decoded 3D Gaussian Splatting scene.
*
* A decoded scene is a CV_32F matrix with one row per Gaussian and #GAUSSIAN_SPLAT_STRIDE columns.
*/
enum GaussianSplatLayout
{
GAUSSIAN_SPLAT_STRIDE = 13, //!< Floats per Gaussian, i.e. the width of a decoded scene.
GAUSSIAN_SPLAT_POS = 0, //!< First column of the position, 3 floats: x, y, z.
GAUSSIAN_SPLAT_COV = 3, //!< First column of the covariance upper triangle, 6 floats: xx, xy, xz, yy, yz, zz.
GAUSSIAN_SPLAT_RGB = 9, //!< First column of the color, 3 floats in [0, 1].
GAUSSIAN_SPLAT_ALPHA = 12 //!< Column of the opacity, 1 float in [0, 1].
};
/** @brief Returns the PLY vertex property names cv::loadGaussianSplats reads by default.
*
* The order of the names is the column order cv::decodeGaussianSplats expects. Pass a renamed copy
* of this list to cv::loadGaussianSplats to read a scene whose exporter spells the properties
* differently, keeping the same order.
*/
CV_EXPORTS_W std::vector<String> getGaussianSplatPlyPropNames();
/** @brief Decodes 3D Gaussian Splatting attributes into a renderable scene.
*
* Scales are exponentiated, opacity is put through a sigmoid, the quaternion and scales are
* combined into a 3D covariance, and the zeroth order harmonic is evaluated to an RGB color.
*
* Use this when the attributes do not come from a file cv::loadGaussianSplats can open, for
* instance when they are held in memory, arrive from a training run, or are read from a resource
* on a platform without filesystem access.
*
* @param attributes CV_32F matrix with one row per Gaussian and one column per name of
* cv::getGaussianSplatPlyPropNames, in that order.
* @param splats Decoded scene, see #GaussianSplatLayout.
*/
CV_EXPORTS_W void decodeGaussianSplats(InputArray attributes, OutputArray splats);
/** @brief Decodes the contents of a `.splat` file into a renderable scene.
*
* A `.splat` file is a headerless array of 32 byte records, one per Gaussian, holding position,
* scale, color and rotation. The values are already activated, so only the covariance is built.
* Being headerless makes this the in memory counterpart of cv::loadGaussianSplats for platforms
* without filesystem access, or for scenes shipped as application resources.
*
* @param buf CV_8U buffer holding the bytes of a `.splat` file.
* @param splats Decoded scene, see #GaussianSplatLayout.
*/
CV_EXPORTS_W void decodeGaussianSplatsPacked(InputArray buf, OutputArray splats);
/** @brief Loads a 3D Gaussian Splatting scene from a PLY or `.splat` file.
*
* Requires a trained scene, i.e. a PLY whose vertices carry `f_dc_0..2`, `opacity`, `scale_0..2`
* and `rot_0..3` alongside `x`, `y`, `z`, or a `.splat` file of 32 byte records. Higher order
* `f_rest_*` harmonics are ignored.
*
* The attributes are decoded on load, see cv::decodeGaussianSplats and
* cv::decodeGaussianSplatsPacked.
*
* @param filename Name of the file, the format is chosen from its extension.
* @param splats Decoded scene in the layout cv::viz3d::showGaussianSplats expects, see
* #GaussianSplatLayout, or empty on failure.
* @param ply_properties Vertex property names to read from a PLY file, in the column order of
* cv::getGaussianSplatPlyPropNames. Empty selects that default list. Unused for `.splat` files.
*/
CV_EXPORTS_W void loadGaussianSplats(const String &filename, OutputArray splats,
const std::vector<String> &ply_properties = std::vector<String>());
/** @brief Loads a mesh from a file.
*
* The function loads mesh from the specified file and returns it.

View File

@@ -137,6 +137,23 @@ each row represents a point.
*/
CV_EXPORTS_W void showPoints(const String& win_name, const String& obj_name, InputArray points);
/** @brief Shows a 3D Gaussian Splatting scene in the specified viz3d window. See cv::viz3d::destroyObject.
Splats are alpha blended, sorted back to front by camera-space depth on the CPU whenever the view
changes, and drawn after all opaque objects in the same window.
Several splat objects in one window are drawn back to front by the depth of their centers. This is
correct for scenes that are apart from each other, but scenes that interpenetrate can blend in the
wrong order where they overlap. Merge such scenes into a single object instead.
@param win_name Name of the viz3d window.
@param obj_name Name of the object.
@param splats One row per Gaussian, 13 floats wide: position (3), upper triangle of the symmetric
3D covariance (6: xx, xy, xz, yy, yz, zz), color (3), opacity (1). As produced by
cv::loadGaussianSplats or cv::decodeGaussianSplats, see cv::GaussianSplatLayout.
*/
CV_EXPORTS_W void showGaussianSplats(const String& win_name, const String& obj_name, InputArray splats);
/** @brief Shows a colored depth map as a point cloud in the specified viz3d window. See cv::viz3d::destroyObject.
The colored depth map must be a 2D image with 4 channels (RGBD). The points are created assuming

View File

@@ -422,6 +422,87 @@ uchar readNext<uchar>(std::ifstream &file, DataFormat format)
return val;
}
static float readPropertyValue(std::ifstream &file, int valType, DataFormat format)
{
switch (valType)
{
case CV_8U:
return (float)readNext<uchar>(file, format);
case CV_8S:
return (float)(schar)readNext<uchar>(file, format);
case CV_16U:
return (float)readNext<ushort>(file, format);
case CV_16S:
return (float)(short)readNext<ushort>(file, format);
case CV_32U:
return (float)readNext<uint>(file, format);
case CV_32S:
return (float)(int)readNext<uint>(file, format);
case CV_32F:
return readNext<float>(file, format);
case CV_64F:
return (float)readNext<double>(file, format);
default:
return 0.f;
}
}
bool PlyDecoder::readNamedProperties(const std::vector<std::string>& names, Mat& values)
{
values.release();
std::ifstream file(m_filename, std::ios::binary);
int nTexCoords = 0;
if (!parseHeader(file, nTexCoords))
return false;
const size_t nProps = m_vertexDescription.properties.size();
std::vector<int> colOf(nProps, -1);
std::vector<bool> got(names.size(), false);
for (size_t j = 0; j < nProps; j++)
{
for (size_t k = 0; k < names.size(); k++)
{
if (m_vertexDescription.properties[j].name == names[k] && !got[k])
{
colOf[j] = (int)k;
got[k] = true;
break;
}
}
}
for (size_t k = 0; k < names.size(); k++)
{
if (!got[k])
{
CV_LOG_ERROR(NULL, "Vertex property " << names[k] << " is not present in the file");
return false;
}
}
values.create((int)m_vertexCount, (int)names.size(), CV_32F);
for (size_t i = 0; i < m_vertexCount; i++)
{
float* row = values.ptr<float>((int)i);
for (size_t j = 0; j < nProps; j++)
{
float v = readPropertyValue(file, m_vertexDescription.properties[j].valType, m_inputDataFormat);
if (colOf[j] >= 0)
row[colOf[j]] = v;
}
if (!file)
{
CV_LOG_ERROR(NULL, "PLY file ended after " << i << " of " << m_vertexCount << " vertices");
values.release();
return false;
}
}
return true;
}
void PlyDecoder::parseBody(std::ifstream &file,
std::vector<Point3f>& points, std::vector<Point3f>& normals,
std::vector<Point3f>& rgb, std::vector<Point3f>& texCoords,

View File

@@ -39,6 +39,8 @@ public:
std::vector<Point3f>& texCoords, int& nTexCoords,
std::vector<std::vector<int32_t>>& indices, int flags) CV_OVERRIDE;
bool readNamedProperties(const std::vector<std::string>& names, Mat& values);
protected:
bool parseHeader(std::ifstream &file, int& nTexCoords);
void parseBody(std::ifstream &file,

View File

@@ -0,0 +1,49 @@
// This file is part of OpenCV project.
// It is subject to the license terms in the LICENSE file found in the top-level directory
// of this distribution and at http://opencv.org/license.html.
// Copyright (C) 2026, BigVision LLC, all rights reserved.
// Third party copyrights are property of their respective owners.
#include "precomp.hpp"
#include "io_splat.hpp"
#include "splat.hpp"
#include <opencv2/core/utils/logger.hpp>
#include <fstream>
#include <vector>
namespace cv {
bool readSplatFile(const std::string& filename, Mat& splats)
{
splats.release();
std::ifstream file(filename, std::ios::binary | std::ios::ate);
if (!file)
{
CV_LOG_ERROR(NULL, "Failed to open '" << filename << "'");
return false;
}
const std::streamoff size = file.tellg();
if (size <= 0 || size % (int)splat::PACKED_STRIDE != 0)
{
CV_LOG_ERROR(NULL, "'" << filename << "' is not a .splat file: size " << size
<< " is not a positive multiple of " << (int)splat::PACKED_STRIDE);
return false;
}
file.seekg(0);
std::vector<uchar> buf((size_t)size);
if (!file.read((char*)buf.data(), size))
{
CV_LOG_ERROR(NULL, "Failed to read '" << filename << "'");
return false;
}
decodeGaussianSplatsPacked(buf, splats);
return true;
}
} /* namespace cv */

View File

@@ -0,0 +1,20 @@
// This file is part of OpenCV project.
// It is subject to the license terms in the LICENSE file found in the top-level directory
// of this distribution and at http://opencv.org/license.html.
// Copyright (C) 2026, BigVision LLC, all rights reserved.
// Third party copyrights are property of their respective owners.
#ifndef _CODERS_SPLAT_H_
#define _CODERS_SPLAT_H_
#include <opencv2/core.hpp>
#include <string>
namespace cv {
// Headerless, no point cloud attributes, so it stays outside BasePointCloudDecoder.
bool readSplatFile(const std::string& filename, Mat& splats);
} /* namespace cv */
#endif

View File

@@ -7,6 +7,7 @@
#include "io_base.hpp"
#include "io_obj.hpp"
#include "io_ply.hpp"
#include "io_splat.hpp"
#include "utils.hpp"
#include "opencv2/core/utils/filesystem.private.hpp"
#include <opencv2/core/utils/logger.hpp>
@@ -83,6 +84,53 @@ void loadPointCloud(const String &filename, OutputArray vertices, OutputArray no
#endif
}
void loadGaussianSplats(const String &filename, OutputArray splats,
const std::vector<String> &ply_properties)
{
splats.release();
#if OPENCV_HAVE_FILESYSTEM_SUPPORT
String file_ext = getExtension(filename);
Mat decoded;
if (file_ext == "splat" || file_ext == "SPLAT")
{
if (!readSplatFile(filename, decoded))
return;
}
else if (file_ext == "ply" || file_ext == "PLY")
{
PlyDecoder decoder;
decoder.setSource(filename);
const std::vector<String> names =
ply_properties.empty() ? getGaussianSplatPlyPropNames() : ply_properties;
Mat raw;
if (!decoder.readNamedProperties(names, raw))
{
CV_LOG_ERROR(NULL, "'" << filename << "' is not a 3D Gaussian Splatting PLY file");
return;
}
decodeGaussianSplats(raw, decoded);
}
else
{
CV_LOG_ERROR(NULL, "Gaussian splats can only be loaded from PLY or SPLAT files, got '" << file_ext << "'");
return;
}
splats.move(decoded);
#else // OPENCV_HAVE_FILESYSTEM_SUPPORT
CV_UNUSED(filename);
CV_UNUSED(splats);
CV_UNUSED(ply_properties);
CV_LOG_WARNING(NULL, "File system support is disabled in this OpenCV build!");
#endif
}
void savePointCloud(const String &filename, InputArray vertices, InputArray normals, InputArray rgb)
{
#if OPENCV_HAVE_FILESYSTEM_SUPPORT

View File

@@ -0,0 +1,118 @@
// This file is part of OpenCV project.
// It is subject to the license terms in the LICENSE file found in the top-level directory
// of this distribution and at http://opencv.org/license.html.
// Copyright (C) 2026, BigVision LLC, all rights reserved.
// Third party copyrights are property of their respective owners.
#include "precomp.hpp"
#include "splat.hpp"
namespace cv {
std::vector<String> getGaussianSplatPlyPropNames()
{
return {
"x", "y", "z",
"f_dc_0", "f_dc_1", "f_dc_2",
"opacity",
"scale_0", "scale_1", "scale_2",
"rot_0", "rot_1", "rot_2", "rot_3"
};
}
static void storeCovariance(float* d, const Matx33f& cov)
{
d[GAUSSIAN_SPLAT_COV + 0] = cov(0, 0);
d[GAUSSIAN_SPLAT_COV + 1] = cov(0, 1);
d[GAUSSIAN_SPLAT_COV + 2] = cov(0, 2);
d[GAUSSIAN_SPLAT_COV + 3] = cov(1, 1);
d[GAUSSIAN_SPLAT_COV + 4] = cov(1, 2);
d[GAUSSIAN_SPLAT_COV + 5] = cov(2, 2);
}
void decodeGaussianSplats(InputArray attributes, OutputArray splats)
{
CV_TRACE_FUNCTION();
Mat raw = attributes.getMat();
CV_Assert(raw.dims == 2 && raw.channels() == 1);
CV_Assert(raw.type() == CV_32F && raw.cols == splat::RAW_STRIDE);
splats.create(raw.rows, GAUSSIAN_SPLAT_STRIDE, CV_32F);
Mat dst = splats.getMat();
parallel_for_(Range(0, raw.rows), [&](const Range& range)
{
for (int i = range.start; i < range.end; i++)
{
const float* s = raw.ptr<float>(i);
float* d = dst.ptr<float>(i);
for (int k = 0; k < 3; k++)
d[GAUSSIAN_SPLAT_POS + k] = s[splat::RAW_OFS_POS + k];
Vec3f scale(std::exp(s[splat::RAW_OFS_SCALE + 0]),
std::exp(s[splat::RAW_OFS_SCALE + 1]),
std::exp(s[splat::RAW_OFS_SCALE + 2]));
storeCovariance(d, splat::covariance(scale,
Vec4f(s[splat::RAW_OFS_ROT + 0], s[splat::RAW_OFS_ROT + 1],
s[splat::RAW_OFS_ROT + 2], s[splat::RAW_OFS_ROT + 3])));
for (int k = 0; k < 3; k++)
d[GAUSSIAN_SPLAT_RGB + k] = splat::shDcToColor(s[splat::RAW_OFS_DC + k]);
d[GAUSSIAN_SPLAT_ALPHA] = splat::sigmoid(s[splat::RAW_OFS_OPACITY]);
}
});
}
void decodeGaussianSplatsPacked(InputArray buf, OutputArray splats)
{
CV_TRACE_FUNCTION();
Mat raw = buf.getMat();
CV_Assert(raw.depth() == CV_8U && raw.isContinuous());
const size_t stride = (size_t)splat::PACKED_STRIDE;
const size_t bytes = raw.total() * raw.elemSize();
CV_Assert(bytes > 0 && bytes % stride == 0);
const uchar* data = raw.ptr<uchar>();
const int n = (int)(bytes / stride);
splats.create(n, GAUSSIAN_SPLAT_STRIDE, CV_32F);
Mat dst = splats.getMat();
// Values arrive already activated, so only the covariance is built.
parallel_for_(Range(0, n), [&](const Range& range)
{
for (int i = range.start; i < range.end; i++)
{
const uchar* s = data + (size_t)i * splat::PACKED_STRIDE;
float* d = dst.ptr<float>(i);
Vec3f pos, scale;
memcpy(pos.val, s + splat::PACKED_OFS_POS, sizeof(pos.val));
memcpy(scale.val, s + splat::PACKED_OFS_SCALE, sizeof(scale.val));
for (int k = 0; k < 3; k++)
d[GAUSSIAN_SPLAT_POS + k] = pos[k];
Vec4f rot;
for (int k = 0; k < 4; k++)
rot[k] = (s[splat::PACKED_OFS_ROT + k] - 128.f) / 128.f;
if (rot.dot(rot) < 1e-12f)
rot = Vec4f(1.f, 0.f, 0.f, 0.f);
storeCovariance(d, splat::covariance(scale, rot));
for (int k = 0; k < 3; k++)
d[GAUSSIAN_SPLAT_RGB + k] = s[splat::PACKED_OFS_RGBA + k] / 255.f;
d[GAUSSIAN_SPLAT_ALPHA] = s[splat::PACKED_OFS_RGBA + 3] / 255.f;
}
});
}
} // namespace cv

View File

@@ -0,0 +1,100 @@
// This file is part of OpenCV project.
// It is subject to the license terms in the LICENSE file found in the top-level directory
// of this distribution and at http://opencv.org/license.html.
// Copyright (C) 2026, BigVision LLC, all rights reserved.
// Third party copyrights are property of their respective owners.
#ifndef _CODERS_SPLAT_INTERNAL_H_
#define _CODERS_SPLAT_INTERNAL_H_
#include "precomp.hpp"
#include <opencv2/core/quaternion.hpp>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <vector>
namespace cv {
// 3D Gaussian Splatting internals shared by the loader, the decoders and the renderer.
// Deliberately free of OpenGL so it stays testable on headless builds.
namespace splat {
enum
{
// Column order of getGaussianSplatPlyPropNames(), which decodeGaussianSplats() reads.
RAW_STRIDE = 14,
RAW_OFS_POS = 0,
RAW_OFS_DC = 3,
RAW_OFS_OPACITY = 6,
RAW_OFS_SCALE = 7,
RAW_OFS_ROT = 10,
// Byte layout of a ".splat" record, which decodeGaussianSplatsPacked() reads.
PACKED_STRIDE = 32,
PACKED_OFS_POS = 0,
PACKED_OFS_SCALE = 12,
PACKED_OFS_RGBA = 24,
PACKED_OFS_ROT = 28
};
inline float sigmoid(float x)
{
return 1.0f / (1.0f + std::exp(-x));
}
inline float shDcToColor(float dc)
{
return std::min(1.0f, std::max(0.0f, 0.5f + 0.28209479177387814f * dc));
}
// Sigma = R S S^T R^T, symmetric positive semi-definite for any rotation and scale.
inline Matx33f covariance(const Vec3f& scale, const Vec4f& rot)
{
Matx33f rs = Quatf(rot[0], rot[1], rot[2], rot[3]).toRotMat3x3(QUAT_ASSUME_NOT_UNIT);
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
rs(i, j) *= scale[j];
return rs * rs.t();
}
// Column of a model-view matrix that gives camera-space z of a point, for the row vector
// convention the viz3d shaders use: z = p * depth_col.xyz + depth_col.w.
inline Vec4f depthColumn(const Matx44f& mv)
{
return Vec4f(mv(0, 2), mv(1, 2), mv(2, 2), mv(3, 2));
}
inline float viewDepth(const Vec4f& depth_col, const float* p)
{
return p[0] * depth_col[0] + p[1] * depth_col[1] + p[2] * depth_col[2] + depth_col[3];
}
// Back to front order of the rows of a Nx3 position matrix, as alpha blending needs.
// Sorts by camera-space depth, the order the reference 3DGS rasterizer composites in,
// so trained scenes blend the way they were optimized.
inline void sortByDepth(const Mat& pos, const Vec4f& depth_col, std::vector<int>& order)
{
CV_Assert(pos.type() == CV_32F && pos.cols == 3);
const int n = pos.rows;
Mat key(1, n, CV_32F);
float* k = key.ptr<float>();
parallel_for_(Range(0, n), [&](const Range& range)
{
for (int i = range.start; i < range.end; i++)
k[i] = viewDepth(depth_col, pos.ptr<float>(i));
});
order.resize(n);
Mat idx(1, n, CV_32S, order.data());
sortIdx(key, idx, SORT_EVERY_ROW | SORT_DESCENDING);
}
} // namespace splat
} // namespace cv
#endif

View File

@@ -7,6 +7,7 @@
#include "../precomp.hpp"
#include "viz3d_private.hpp"
#include "grid_ticks.hpp"
#include "../splat.hpp"
#include "opencv2/core/utils/logger.hpp"
#include "opencv2/imgproc.hpp"
@@ -543,6 +544,21 @@ void showPoints(const String& win_name, const String& obj_name, InputArray point
#endif
}
void showGaussianSplats(const String& win_name, const String& obj_name, InputArray splats)
{
CV_TRACE_FUNCTION();
#ifndef HAVE_OPENGL
CV_UNUSED(win_name);
CV_UNUSED(obj_name);
CV_UNUSED(splats);
CV_Error(cv::Error::OpenGlNotSupported, "The library is compiled without OpenGL support");
#else
Window* win = getWindow(win_name);
win->set(obj_name, makePtr<GaussianSplats>(splats));
updateWindow(win_name);
#endif
}
void showRGBD(const String& win_name, const String& obj_name, InputArray img, const Matx33f& intrinsics, float scale)
{
CV_TRACE_FUNCTION();
@@ -664,6 +680,7 @@ View::View()
this->up = { 0.0f, 1.0f, 0.0f };
this->aspect = 1.0f;
this->viewport = Size(1, 1);
this->setPerspective(1.3f, 0.1f, 2000.0f);
this->lookAt(this->origin, { 0.0f, 1.0f, 0.0f });
}
@@ -677,6 +694,11 @@ void View::setAspect(float aspect_)
}
}
void View::setViewport(Size viewport_)
{
this->viewport = viewport_;
}
void View::setPerspective(float fov_, float z_near_, float z_far_)
{
this->fov = fov_;
@@ -972,9 +994,11 @@ void Window::draw()
Rect rect = getWindowImageRect(this->name);
float aspect = static_cast<float>(rect.width) / static_cast<float>(rect.height);
this->view.setAspect(aspect);
this->view.setViewport(rect.size());
ogl::enable(ogl::DEPTH_TEST);
ogl::clearColor(this->sky_color * 255.0f);
ogl::clearDepth(1.0f);
if (this->grid)
{
@@ -988,6 +1012,23 @@ void Window::draw()
}
for (auto& obj : this->objects)
if (!obj.second->isTransparent())
obj.second->draw(this->view, this->sun);
std::vector<std::pair<float, Object*>> transparent;
for (auto& obj : this->objects)
{
if (obj.second->isTransparent())
{
const Vec3f c = obj.second->getCenter();
const Vec4f depth_col = splat::depthColumn(obj.second->getModel() * this->view.getView());
transparent.emplace_back(splat::viewDepth(depth_col, c.val), obj.second.get());
}
}
std::stable_sort(transparent.begin(), transparent.end(),
[](const std::pair<float, Object*>& a, const std::pair<float, Object*>& b)
{ return a.first > b.first; });
for (auto& obj : transparent)
obj.second->draw(this->view, this->sun);
}
@@ -1534,6 +1575,255 @@ void PointCloud::setShader(ogl::Program program_)
this->proj_loc = this->program.getUniformLocation("proj");
}
GaussianSplats::GaussianSplats(InputArray splats_)
{
CV_Assert(splats_.channels() == 1 && splats_.dims() == 2 && splats_.size().width == GAUSSIAN_SPLAT_STRIDE);
CV_Assert(splats_.depth() == CV_32F);
CV_Assert(splats_.size().height > 0);
Mat src = splats_.getMat();
this->count = src.rows;
// Only the positions are kept on the CPU, for the depth sort.
this->pos = src.colRange(0, 3).clone();
Scalar mean_pos = mean(this->pos.reshape(3));
this->center = Vec3f(static_cast<float>(mean_pos[0]), static_cast<float>(mean_pos[1]),
static_cast<float>(mean_pos[2]));
Mat packed(this->count * 4, 1, CV_32FC4, Scalar::all(0.0));
Vec4f* texel = packed.ptr<Vec4f>(0);
for (int i = 0; i < this->count; i++)
{
const float* s = src.ptr<float>(i);
texel[i * 4 + 0] = Vec4f(s[0], s[1], s[2], s[GAUSSIAN_SPLAT_ALPHA]);
texel[i * 4 + 1] = Vec4f(s[GAUSSIAN_SPLAT_COV + 0], s[GAUSSIAN_SPLAT_COV + 1], s[GAUSSIAN_SPLAT_COV + 2], 0.0f);
texel[i * 4 + 2] = Vec4f(s[GAUSSIAN_SPLAT_COV + 3], s[GAUSSIAN_SPLAT_COV + 4], s[GAUSSIAN_SPLAT_COV + 5], 0.0f);
texel[i * 4 + 3] = Vec4f(s[GAUSSIAN_SPLAT_RGB + 0], s[GAUSSIAN_SPLAT_RGB + 1], s[GAUSSIAN_SPLAT_RGB + 2], 0.0f);
}
this->data.copyFrom(packed, ogl::Buffer::TEXTURE_BUFFER);
this->data_tex.create(this->data, ogl::TextureBuffer::RGBA32F);
this->order_cpu.resize(this->count);
for (int i = 0; i < this->count; i++)
this->order_cpu[i] = i;
this->order.copyFrom(Mat(this->count, 1, CV_32S, this->order_cpu.data()), ogl::Buffer::TEXTURE_BUFFER);
this->order_tex.create(this->order, ogl::TextureBuffer::R32I);
float quad_data[] = {
-1.0f, -1.0f,
+1.0f, -1.0f,
+1.0f, +1.0f,
-1.0f, +1.0f,
};
this->quad.copyFrom(Mat(4, 2, CV_32F, quad_data), ogl::Buffer::ARRAY_BUFFER);
int quad_index_data[] = { 0, 1, 2, 2, 3, 0 };
this->quad_indices.copyFrom(Mat(2, 3, CV_32S, quad_index_data), ogl::Buffer::ELEMENT_ARRAY_BUFFER);
this->va.create({
{
this->quad,
2 * sizeof(float), 0,
2, ogl::Attribute::FLOAT,
false, false,
0
}
});
this->sorted = false;
this->last_mv = Matx44f::eye();
}
void GaussianSplats::reorder(const Matx44f& mv)
{
splat::sortByDepth(this->pos, splat::depthColumn(mv), this->order_cpu);
this->order.copyFrom(Mat(this->count, 1, CV_32S, this->order_cpu.data()), ogl::Buffer::TEXTURE_BUFFER);
}
void GaussianSplats::draw(const View& view, const Light& light)
{
CV_UNUSED(light);
Matx44f model_ = this->getModel();
// Depth depends on the view direction as well as the camera position, so the sort is
// keyed on the whole model-view matrix, the same one the shader projects with.
Matx44f mv = model_ * view.getView();
if (!this->sorted || norm(mv - this->last_mv) > 0.0)
{
this->reorder(mv);
this->last_mv = mv;
this->sorted = true;
}
this->program.bind();
this->va.bind();
Matx44f proj = view.getProj();
Size vp = view.getViewport();
ogl::Program::setUniformMat4x4(this->model_loc, model_);
ogl::Program::setUniformMat4x4(this->view_loc, view.getView());
ogl::Program::setUniformMat4x4(this->proj_loc, proj);
ogl::Program::setUniformVec2(this->focal_loc, Vec2f(0.5f * vp.width * proj(0, 0),
0.5f * vp.height * proj(1, 1)));
ogl::Program::setUniformVec2(this->viewport_loc, Vec2f(static_cast<float>(vp.width),
static_cast<float>(vp.height)));
ogl::Program::setUniform1f(this->z_near_loc, view.getZNear());
// Unit 0 is bound last so the active texture unit is left where other objects expect it.
this->order_tex.bind(1);
ogl::Program::setUniform1i(this->order_loc, 1);
this->data_tex.bind(0);
ogl::Program::setUniform1i(this->data_loc, 0);
ogl::enable(ogl::BLEND);
ogl::blendFunc(ogl::BLEND_SRC_ALPHA, ogl::BLEND_ONE_MINUS_SRC_ALPHA);
ogl::depthMask(false);
this->quad_indices.bind(ogl::Buffer::ELEMENT_ARRAY_BUFFER);
ogl::drawElementsInstanced(0, 6, ogl::UNSIGNED_INT, ogl::TRIANGLES, this->count);
ogl::depthMask(true);
ogl::disable(ogl::BLEND);
}
String GaussianSplats::getShaderName()
{
return "splats";
}
ogl::Program GaussianSplats::buildShader()
{
auto vs = ogl::Shader(R"(
#version 330 core
layout (location = 0) in vec2 quad;
out vec3 frag_color;
out vec2 frag_offset;
out float frag_alpha;
uniform mat4 model;
uniform mat4 view;
uniform mat4 proj;
uniform vec2 focal;
uniform vec2 viewport;
uniform float z_near;
uniform samplerBuffer splat_data;
uniform isamplerBuffer splat_order;
const float CUTOFF = 3.0;
const float LOWPASS = 0.3;
const float MAX_EXTENT = 0.75;
void main() {
int id = texelFetch(splat_order, gl_InstanceID).r;
vec4 d0 = texelFetch(splat_data, id * 4 + 0);
vec4 d1 = texelFetch(splat_data, id * 4 + 1);
vec4 d2 = texelFetch(splat_data, id * 4 + 2);
vec4 d3 = texelFetch(splat_data, id * 4 + 3);
mat4 mv = model * view;
vec4 cam = vec4(d0.xyz, 1.0) * mv;
frag_color = d3.rgb;
frag_alpha = d0.w;
frag_offset = quad * CUTOFF;
if (cam.z <= z_near) {
gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
return;
}
mat3 sigma = mat3(d1.x, d1.y, d1.z,
d1.y, d2.x, d2.y,
d1.z, d2.y, d2.z);
mat3 xf = mat3(mv);
mat3 sigma_cam = transpose(xf) * sigma * xf;
float z2 = cam.z * cam.z;
mat3x2 j = mat3x2(focal.x / cam.z, 0.0,
0.0, focal.y / cam.z,
-focal.x * cam.x / z2, -focal.y * cam.y / z2);
mat2 cov = j * sigma_cam * transpose(j);
float det_raw = determinant(cov);
cov[0][0] += LOWPASS;
cov[1][1] += LOWPASS;
frag_alpha *= sqrt(max(0.0, det_raw / max(determinant(cov), 1e-9)));
float a = cov[0][0];
float b = cov[0][1];
float c = cov[1][1];
float mid = 0.5 * (a + c);
float disc = sqrt(max(0.0, mid * mid - (a * c - b * b)));
float l1 = mid + disc;
float l2 = max(mid - disc, 0.0);
float r1 = CUTOFF * sqrt(l1);
float r2 = CUTOFF * sqrt(l2);
if (l1 <= 0.0 || r1 > MAX_EXTENT * max(viewport.x, viewport.y)) {
gl_Position = vec4(2.0, 2.0, 2.0, 1.0);
return;
}
vec2 e1;
if (abs(b) < 1e-9)
e1 = (a >= c) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
else
e1 = normalize(vec2(b, l1 - a));
vec2 e2 = vec2(-e1.y, e1.x);
vec2 offset = quad.x * r1 * e1 + quad.y * r2 * e2;
vec4 clip = cam * proj;
clip.xy += offset * 2.0 / viewport * clip.w;
gl_Position = clip;
}
)", ogl::Shader::VERTEX);
auto fs = ogl::Shader(R"(
#version 330 core
in vec3 frag_color;
in vec2 frag_offset;
in float frag_alpha;
out vec4 color;
// Below one 8-bit level the contribution is invisible.
const float MIN_ALPHA = 1.0 / 255.0;
void main() {
float alpha = frag_alpha * exp(-0.5 * dot(frag_offset, frag_offset));
if (alpha < MIN_ALPHA)
discard;
color = vec4(frag_color, alpha);
}
)", ogl::Shader::FRAGMENT);
return ogl::Program(vs, fs);
}
void GaussianSplats::setShader(ogl::Program program_)
{
this->program = program_;
this->model_loc = this->program.getUniformLocation("model");
this->view_loc = this->program.getUniformLocation("view");
this->proj_loc = this->program.getUniformLocation("proj");
this->focal_loc = this->program.getUniformLocation("focal");
this->viewport_loc = this->program.getUniformLocation("viewport");
this->z_near_loc = this->program.getUniformLocation("z_near");
this->data_loc = this->program.getUniformLocation("splat_data");
this->order_loc = this->program.getUniformLocation("splat_order");
}
#endif // HAVE_OPENGL
} // namespace viz3d

View File

@@ -26,6 +26,7 @@ public:
View();
void setAspect(float aspect);
void setViewport(Size viewport);
void setPerspective(float fov, float z_near, float z_far);
void rotate(float dx, float dy); // Rotates the camera using mouse input
@@ -37,6 +38,8 @@ public:
inline float getDistance() const { return this->distance; }
inline Matx44f getView() const { return this->view; }
inline Matx44f getProj() const { return this->proj; }
inline Size getViewport() const { return this->viewport; }
inline float getZNear() const { return this->z_near; }
private:
void lookAt(const Vec3f& point, const Vec3f& up);
@@ -45,6 +48,7 @@ private:
float fov;
float z_near;
float z_far;
Size viewport;
Matx44f proj;
Matx44f view;
@@ -79,6 +83,11 @@ public:
virtual ogl::Program buildShader() = 0;
virtual void setShader(ogl::Program program) = 0;
// Alpha-blended objects must be drawn after all opaque ones. See Window::draw.
virtual bool isTransparent() const { return false; }
// Point in object space by which transparent objects are ordered against each other.
virtual Vec3f getCenter() const { return Vec3f::all(0.0f); }
inline Matx44f getModel() const { return this->model; }
private:
@@ -203,6 +212,50 @@ private:
int proj_loc;
};
// 3D Gaussian Splatting object.
class GaussianSplats : public Object
{
public:
GaussianSplats(InputArray splats);
virtual void draw(const View& view, const Light& light) override;
virtual String getShaderName() override;
virtual ogl::Program buildShader() override;
virtual void setShader(ogl::Program program) override;
virtual bool isTransparent() const override { return true; }
virtual Vec3f getCenter() const override { return this->center; }
private:
void reorder(const Matx44f& mv);
ogl::Program program;
ogl::VertexArray va;
ogl::Buffer quad;
ogl::Buffer quad_indices;
ogl::Buffer data;
ogl::Buffer order;
ogl::TextureBuffer data_tex;
ogl::TextureBuffer order_tex;
Mat pos;
std::vector<int> order_cpu;
Vec3f center;
Matx44f last_mv;
bool sorted;
int count;
int model_loc;
int view_loc;
int proj_loc;
int focal_loc;
int viewport_loc;
int z_near_loc;
int data_loc;
int order_loc;
};
}} // namespace cv::viz3d
#endif // HAVE_OPENGL

View File

@@ -7,6 +7,10 @@
#include "test_precomp.hpp"
#include <opencv2/highgui.hpp> // updateWindow / destroyWindow
#include "../src/viz3d/grid_ticks.hpp" // GL-free grid spacing helper
#include "../src/splat.hpp" // GL-free 3DGS decode/sort helpers
#include <cstdio>
#include <fstream>
namespace opencv_test { namespace {
@@ -28,6 +32,295 @@ TEST(Viz3D, grid_tick_step)
}
}
// Identity rotation + unit scale must give the identity covariance. GL-free.
TEST(Splat, covariance_identity)
{
Matx33f cov = splat::covariance(Vec3f(1.f, 1.f, 1.f), Vec4f(1.f, 0.f, 0.f, 0.f));
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
EXPECT_NEAR(cov(i, j), (i == j) ? 1.f : 0.f, 1e-5f) << "at " << i << "," << j;
}
// Axis-aligned scales must land on the diagonal as squares, and a non-unit
// quaternion must still be treated as a rotation (i.e. normalized).
TEST(Splat, covariance_scale_and_normalization)
{
Matx33f cov = splat::covariance(Vec3f(2.f, 3.f, 4.f), Vec4f(1.f, 0.f, 0.f, 0.f));
EXPECT_NEAR(cov(0, 0), 4.f, 1e-4f);
EXPECT_NEAR(cov(1, 1), 9.f, 1e-4f);
EXPECT_NEAR(cov(2, 2), 16.f, 1e-4f);
EXPECT_NEAR(cov(0, 1), 0.f, 1e-4f);
Matx33f scaled = splat::covariance(Vec3f(1.f, 1.f, 1.f), Vec4f(7.f, 0.f, 0.f, 0.f));
for (int i = 0; i < 3; i++)
EXPECT_NEAR(scaled(i, i), 1.f, 1e-5f) << "unnormalized quaternion changed the shape";
}
// A 90 degree rotation about Z must swap the X and Y variances.
TEST(Splat, covariance_rotation)
{
const float s = std::sqrt(0.5f);
Matx33f cov = splat::covariance(Vec3f(2.f, 3.f, 4.f), Vec4f(s, 0.f, 0.f, s));
EXPECT_NEAR(cov(0, 0), 9.f, 1e-3f);
EXPECT_NEAR(cov(1, 1), 4.f, 1e-3f);
EXPECT_NEAR(cov(2, 2), 16.f, 1e-3f);
EXPECT_NEAR(cov(0, 1), 0.f, 1e-3f);
}
// Covariance must stay symmetric for arbitrary inputs.
TEST(Splat, covariance_symmetric)
{
RNG rng(0x5eed);
for (int t = 0; t < 64; t++)
{
Vec3f scale(rng.uniform(0.01f, 3.f), rng.uniform(0.01f, 3.f), rng.uniform(0.01f, 3.f));
Vec4f q(rng.uniform(-1.f, 1.f), rng.uniform(-1.f, 1.f),
rng.uniform(-1.f, 1.f), rng.uniform(-1.f, 1.f));
if (cv::norm(q) < 1e-3)
continue;
Matx33f cov = splat::covariance(scale, q);
EXPECT_NEAR(cov(0, 1), cov(1, 0), 1e-4f);
EXPECT_NEAR(cov(0, 2), cov(2, 0), 1e-4f);
EXPECT_NEAR(cov(1, 2), cov(2, 1), 1e-4f);
EXPECT_GT(cov(0, 0), 0.f);
EXPECT_GT(cov(1, 1), 0.f);
EXPECT_GT(cov(2, 2), 0.f);
}
}
TEST(Splat, decode_activations)
{
Mat raw = Mat::zeros(1, splat::RAW_STRIDE, CV_32F);
float* r = raw.ptr<float>(0);
r[0] = 1.f; r[1] = 2.f; r[2] = 3.f; // position
r[10] = 1.f; // identity quaternion, zero scale, zero opacity, zero f_dc
Mat splats;
decodeGaussianSplats(raw, splats);
ASSERT_EQ(splats.rows, 1);
ASSERT_EQ(splats.cols, (int)GAUSSIAN_SPLAT_STRIDE);
const float* d = splats.ptr<float>(0);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_POS + 0], 1.f, 1e-6f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_POS + 1], 2.f, 1e-6f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_POS + 2], 3.f, 1e-6f);
// exp(0) == 1 on every axis, so the covariance is the identity
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 0], 1.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 3], 1.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 5], 1.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 1], 0.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_RGB + 0], 0.5f, 1e-5f); // 0.5 + C0 * 0
EXPECT_NEAR(d[GAUSSIAN_SPLAT_ALPHA], 0.5f, 1e-5f); // sigmoid(0)
}
// Sorting must run strictly far to near, which is what alpha blending requires.
TEST(Splat, sort_far_to_near)
{
Mat pos = Mat::zeros(4, 3, CV_32F);
const float xs[] = { 1.f, 8.f, 4.f, 2.f };
for (int i = 0; i < 4; i++)
pos.ptr<float>(i)[0] = xs[i];
// Depth taken along +x.
std::vector<int> order;
splat::sortByDepth(pos, Vec4f(1.f, 0.f, 0.f, 0.f), order);
ASSERT_EQ(order.size(), (size_t)4);
EXPECT_EQ(order[0], 1);
EXPECT_EQ(order[1], 2);
EXPECT_EQ(order[2], 3);
EXPECT_EQ(order[3], 0);
for (size_t i = 1; i < order.size(); i++)
{
float prev = pos.ptr<float>(order[i - 1])[0];
float cur = pos.ptr<float>(order[i])[0];
EXPECT_GE(prev, cur);
}
}
// A point far off to the side is further from the camera but shallower in depth.
// Sorting must follow depth, as the reference 3DGS rasterizer does, not distance.
TEST(Splat, sort_by_view_depth_not_distance)
{
const float data[] = {
0.f, 0.f, 5.f, // on axis: distance 5, depth 5
10.f, 0.f, 4.f // off axis: distance ~10.8, depth 4
};
Mat pos(2, 3, CV_32F, (void*)data);
std::vector<int> order;
splat::sortByDepth(pos, splat::depthColumn(Matx44f::eye()), order);
ASSERT_EQ(order.size(), (size_t)2);
EXPECT_EQ(order[0], 0);
EXPECT_EQ(order[1], 1);
}
// depthColumn must reproduce the camera-space z the shaders get from vec4(p, 1) * model * view.
TEST(Splat, depth_column_matches_row_vector_transform)
{
// A camera at (1, 2, -3) looking along +z: view rows as View::lookAt builds them.
const Vec3f eye(1.f, 2.f, -3.f);
const Vec3f s(1.f, 0.f, 0.f), u(0.f, 1.f, 0.f), f(0.f, 0.f, 1.f);
Matx44f view(s(0), u(0), f(0), 0.f,
s(1), u(1), f(1), 0.f,
s(2), u(2), f(2), 0.f,
-s.dot(eye), -u.dot(eye), -f.dot(eye), 1.f);
// A model that moves the object by (0, 0, 2).
Matx44f model = Matx44f::eye();
model(3, 2) = 2.f;
const Matx44f mv = model * view;
const float p[] = { 4.f, -1.f, 7.f };
Matx<float, 1, 4> cam = Matx<float, 1, 4>(p[0], p[1], p[2], 1.f) * mv;
// Object z 7, moved to 9 in the world, 12 in front of a camera at z = -3.
EXPECT_NEAR(cam(0, 2), 12.f, 1e-5f);
EXPECT_NEAR(splat::viewDepth(splat::depthColumn(mv), p), cam(0, 2), 1e-5f);
}
// Writes a minimal 3DGS PLY. Properties are deliberately out of order and an
// unrequested one is included, to prove the reader maps by name and skips extras.
static void writeSplatPly(const std::string& path)
{
std::ofstream f(path);
f << "ply\n"
<< "format ascii 1.0\n"
<< "element vertex 2\n"
<< "property float x\n"
<< "property float y\n"
<< "property float z\n"
<< "property float f_rest_0\n"
<< "property float opacity\n"
<< "property float scale_0\n"
<< "property float scale_1\n"
<< "property float scale_2\n"
<< "property float rot_0\n"
<< "property float rot_1\n"
<< "property float rot_2\n"
<< "property float rot_3\n"
<< "property float f_dc_0\n"
<< "property float f_dc_1\n"
<< "property float f_dc_2\n"
<< "end_header\n"
<< "1 2 3 99 0 0 0 0 1 0 0 0 0 0 0\n"
<< "4 5 6 99 0 0 0 0 1 0 0 0 0 0 0\n";
}
TEST(Splat, load_ply)
{
std::string path = tempfile("splats.ply");
writeSplatPly(path);
Mat splats;
loadGaussianSplats(path, splats);
remove(path.c_str());
ASSERT_FALSE(splats.empty());
ASSERT_EQ(splats.rows, 2);
ASSERT_EQ(splats.cols, (int)GAUSSIAN_SPLAT_STRIDE);
EXPECT_NEAR(splats.ptr<float>(0)[0], 1.f, 1e-6f);
EXPECT_NEAR(splats.ptr<float>(0)[2], 3.f, 1e-6f);
EXPECT_NEAR(splats.ptr<float>(1)[0], 4.f, 1e-6f);
EXPECT_NEAR(splats.ptr<float>(1)[2], 6.f, 1e-6f);
for (int i = 0; i < 2; i++)
{
const float* d = splats.ptr<float>(i);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_ALPHA], 0.5f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 0], 1.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_RGB + 0], 0.5f, 1e-5f);
}
}
// A PLY without the 3DGS attributes must fail cleanly rather than return garbage.
TEST(Splat, load_ply_rejects_plain_cloud)
{
std::string path = tempfile("plain.ply");
{
std::ofstream f(path);
f << "ply\nformat ascii 1.0\nelement vertex 1\n"
<< "property float x\nproperty float y\nproperty float z\n"
<< "end_header\n0 0 0\n";
}
Mat splats;
loadGaussianSplats(path, splats);
remove(path.c_str());
EXPECT_TRUE(splats.empty());
}
static void writeSplatFile(const std::string& path, int n)
{
std::ofstream f(path, std::ios::binary);
for (int i = 0; i < n; i++)
{
const float pos[3] = { 1.f + 3 * i, 2.f + 3 * i, 3.f + 3 * i };
const float scale[3] = { 1.f, 1.f, 1.f };
const uchar rgba[4] = { 128, 128, 128, 128 };
const uchar rot[4] = { 255, 128, 128, 128 };
f.write((const char*)pos, sizeof(pos));
f.write((const char*)scale, sizeof(scale));
f.write((const char*)rgba, sizeof(rgba));
f.write((const char*)rot, sizeof(rot));
}
}
TEST(Splat, load_splat)
{
std::string path = tempfile("splats.splat");
writeSplatFile(path, 2);
Mat splats;
loadGaussianSplats(path, splats);
remove(path.c_str());
ASSERT_FALSE(splats.empty());
ASSERT_EQ(splats.rows, 2);
ASSERT_EQ(splats.cols, (int)GAUSSIAN_SPLAT_STRIDE);
EXPECT_NEAR(splats.ptr<float>(0)[0], 1.f, 1e-6f);
EXPECT_NEAR(splats.ptr<float>(0)[2], 3.f, 1e-6f);
EXPECT_NEAR(splats.ptr<float>(1)[0], 4.f, 1e-6f);
EXPECT_NEAR(splats.ptr<float>(1)[2], 6.f, 1e-6f);
for (int i = 0; i < 2; i++)
{
const float* d = splats.ptr<float>(i);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_ALPHA], 128.f / 255.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_RGB + 0], 128.f / 255.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 0], 1.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 3], 1.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 5], 1.f, 1e-5f);
EXPECT_NEAR(d[GAUSSIAN_SPLAT_COV + 1], 0.f, 1e-5f);
}
}
// A record is 32 bytes, so anything else is truncated or not a splat file at all.
TEST(Splat, load_splat_rejects_partial_record)
{
std::string path = tempfile("partial.splat");
writeSplatFile(path, 1);
{
std::ofstream f(path, std::ios::binary | std::ios::app);
const uchar tail[8] = { 0 };
f.write((const char*)tail, sizeof(tail));
}
Mat splats;
loadGaussianSplats(path, splats);
remove(path.c_str());
EXPECT_TRUE(splats.empty());
}
// viz3d needs a GL context; skip when none is available (headless CI).
static bool viz3dAvailable()
{
@@ -74,4 +367,70 @@ TEST(Viz3D, render_scene_smoke)
destroyAllWindows();
}
// Exercises the splat path: TextureBuffer, instanced draw, blend/depthMask state,
// and that splats compose with opaque objects in the same window.
TEST(Viz3D, render_splats_smoke)
{
if (!viz3dAvailable())
throw cvtest::SkipTestException("viz3d/OpenGL not available (no GL context)");
const String w = "viz3d_splat_test";
Mat raw(64, splat::RAW_STRIDE, CV_32F, Scalar::all(0.0));
RNG rng(0x5B1A7C);
for (int i = 0; i < raw.rows; i++)
{
float* r = raw.ptr<float>(i);
r[0] = rng.uniform(-2.f, 2.f);
r[1] = rng.uniform(-2.f, 2.f);
r[2] = rng.uniform(-2.f, 2.f);
r[6] = 1.0f; // opacity -> sigmoid
r[7] = r[8] = r[9] = -2.0f; // small scales
r[10] = 1.0f; // identity quaternion
}
Mat splats;
decodeGaussianSplats(raw, splats);
EXPECT_NO_THROW(viz3d::showGaussianSplats(w, "splats", splats));
// An opaque object in the same window: splats must be drawn after it.
Mat pts(16, 6, CV_32F);
randu(pts, 0.0f, 1.0f);
EXPECT_NO_THROW(viz3d::showPoints(w, "pts", pts));
for (int i = 0; i < 4; ++i)
EXPECT_NO_THROW(updateWindow(w));
// Changing the model matrix must invalidate the depth order, since the sort
// runs in object space.
EXPECT_NO_THROW(viz3d::setObjectPosition(w, "splats", Vec3f(1.0f, 0.0f, 0.0f)));
EXPECT_NO_THROW(updateWindow(w));
EXPECT_NO_THROW(viz3d::destroyObject(w, "splats"));
destroyAllWindows();
}
// A single centered splat must not throw and must survive repeated redraws.
TEST(Viz3D, render_single_splat)
{
if (!viz3dAvailable())
throw cvtest::SkipTestException("viz3d/OpenGL not available (no GL context)");
const String w = "viz3d_single_splat";
Mat raw = Mat::zeros(1, splat::RAW_STRIDE, CV_32F);
raw.ptr<float>(0)[6] = 2.0f;
raw.ptr<float>(0)[10] = 1.0f;
Mat splats;
decodeGaussianSplats(raw, splats);
EXPECT_NO_THROW(viz3d::showGaussianSplats(w, "one", splats));
for (int i = 0; i < 4; ++i)
EXPECT_NO_THROW(updateWindow(w));
destroyAllWindows();
}
}} // namespace

184
samples/ptcloud/splats.cpp Normal file
View File

@@ -0,0 +1,184 @@
// This file is part of OpenCV project.
// It is subject to the license terms in the LICENSE file found in the top-level directory
// of this distribution and at http://opencv.org/license.html.
// Copyright (C) 2026, BigVision LLC, all rights reserved.
// Third party copyrights are property of their respective owners.
#include <opencv2/core.hpp>
#include <opencv2/highgui.hpp>
#include <opencv2/ptcloud.hpp>
#include <algorithm>
#include <cfloat>
#include <iostream>
using namespace cv;
int main(int argc, char** argv)
{
const char* keys =
"{ help h | | print this message }"
"{ @splats | | path to a trained 3D Gaussian Splatting .ply or .splat file }"
"{ points | false | also show the splat centers as an opaque point cloud }"
"{ raw | false | keep the file's world coordinates instead of recentering }"
"{ flip | true | rotate 180 deg about X, for the COLMAP Y-down convention }"
"{ fit | 8.0 | half-extent to scale the scene to when recentering }"
"{ size | 0.5 | splat sigma as a multiple of point spacing, for plain point clouds }";
CommandLineParser parser(argc, argv, keys);
parser.about("Renders a 3D Gaussian Splatting scene with cv::viz3d::showGaussianSplats.");
if (!parser.check())
{
parser.printErrors();
return 1;
}
if (parser.has("help") || !parser.has("@splats"))
{
parser.printMessage();
std::cout << "\nA trained 3DGS scene is not bundled with OpenCV; pass your own, e.g.\n"
<< " ./example_ptcloud_splats /path/to/point_cloud.ply\n"
<< " ./example_ptcloud_splats /path/to/scene.splat\n"
<< "A plain colored point cloud also works, rendered as isotropic splats:\n"
<< " ./example_ptcloud_splats ../samples/data/anthidium-forcipatum.ply\n";
return 0;
}
const String filename = parser.get<String>("@splats");
Mat splats;
loadGaussianSplats(filename, splats);
const bool trained = !splats.empty();
if (!trained)
{
Mat verts, rgb;
loadPointCloud(filename, verts, noArray(), rgb);
if (verts.empty())
{
std::cerr << "Failed to load '" << filename << "' as splats or as a point cloud.\n";
return 1;
}
verts = verts.reshape(1, verts.rows);
if (!rgb.empty())
rgb = rgb.reshape(1, rgb.rows);
Vec3f plo(FLT_MAX, FLT_MAX, FLT_MAX), phi(-FLT_MAX, -FLT_MAX, -FLT_MAX);
for (int i = 0; i < verts.rows; i++)
for (int k = 0; k < 3; k++)
{
plo[k] = std::min(plo[k], verts.at<float>(i, k));
phi[k] = std::max(phi[k], verts.at<float>(i, k));
}
// Scanned clouds sample a surface, not a volume, so spacing goes as sqrt(area/N).
Vec3f ext;
for (int k = 0; k < 3; k++)
ext[k] = std::max(1e-6f, phi[k] - plo[k]);
double area = 2.0 * (ext[0] * ext[1] + ext[1] * ext[2] + ext[2] * ext[0]);
float sigma = parser.get<float>("size")
* (float)std::sqrt(area / std::max(1, verts.rows));
splats = Mat::zeros(verts.rows, 13, CV_32F);
for (int i = 0; i < verts.rows; i++)
{
float* d = splats.ptr<float>(i);
for (int k = 0; k < 3; k++)
d[k] = verts.at<float>(i, k);
d[3] = d[6] = d[8] = sigma * sigma;
for (int k = 0; k < 3; k++)
d[9 + k] = rgb.empty() ? 0.8f : rgb.at<float>(i, k);
d[12] = 1.0f;
}
std::cout << "'" << filename << "' is not a trained 3DGS file; loaded it as a point cloud "
<< "and synthesized isotropic splats with sigma " << sigma
<< " (tune with --size)" << std::endl;
}
std::cout << "Loaded " << splats.rows << " splats from " << filename << std::endl;
if (trained && parser.get<bool>("flip"))
{
for (int i = 0; i < splats.rows; i++)
{
float* p = splats.ptr<float>(i);
p[1] = -p[1];
p[2] = -p[2];
p[4] = -p[4];
p[5] = -p[5];
}
std::cout << "rotated 180 degrees about X for the COLMAP Y-down convention"
<< " (pass --flip=false to disable)" << std::endl;
}
Mat loM, hiM;
reduce(splats.colRange(0, 3), loM, 0, REDUCE_MIN);
reduce(splats.colRange(0, 3), hiM, 0, REDUCE_MAX);
Vec3f lo(loM.ptr<float>()), hi(hiM.ptr<float>());
std::cout << "bounds min [" << lo[0] << ", " << lo[1] << ", " << lo[2] << "]"
<< " max [" << hi[0] << ", " << hi[1] << ", " << hi[2] << "]" << std::endl;
if (!parser.get<bool>("raw"))
{
std::vector<float> axis(splats.rows);
Vec3f med(0.f, 0.f, 0.f);
for (int k = 0; k < 3; k++)
{
for (int i = 0; i < splats.rows; i++)
axis[i] = splats.ptr<float>(i)[k];
std::nth_element(axis.begin(), axis.begin() + axis.size() / 2, axis.end());
med[k] = axis[axis.size() / 2];
}
std::vector<float> rad(splats.rows);
for (int i = 0; i < splats.rows; i++)
{
const float* p = splats.ptr<float>(i);
rad[i] = std::max(std::max(std::abs(p[0] - med[0]), std::abs(p[1] - med[1])),
std::abs(p[2] - med[2]));
}
size_t q = (size_t)(rad.size() * 0.9);
std::nth_element(rad.begin(), rad.begin() + q, rad.end());
float extent = rad[q];
std::cout << "median [" << med[0] << ", " << med[1] << ", " << med[2]
<< "] p90 half-extent " << extent << std::endl;
float scale = (extent > 0.0f) ? parser.get<float>("fit") / extent : 1.0f;
for (int i = 0; i < splats.rows; i++)
{
float* p = splats.ptr<float>(i);
for (int k = 0; k < 3; k++)
p[k] = (p[k] - med[k]) * scale;
for (int k = 0; k < 6; k++)
p[3 + k] *= scale * scale;
}
std::cout << "recentered on the origin and scaled by " << scale
<< " (pass --raw to disable)" << std::endl;
}
viz3d::setPerspective("splats", 1.3f, 0.01f, 1000.0f);
viz3d::showGaussianSplats("splats", "scene", splats);
if (parser.get<bool>("points"))
{
Mat centers(splats.rows, 6, CV_32F);
splats.colRange(0, 3).copyTo(centers.colRange(0, 3));
splats.colRange(9, 12).copyTo(centers.colRange(3, 6));
viz3d::showPoints("splats", "centers", centers);
}
std::cout << "Drag with the left mouse button to orbit, right to pan, wheel to zoom. "
<< "Press Esc to quit." << std::endl;
while (waitKey(16) != 27)
;
return 0;
}