1
0
mirror of https://github.com/opencv/opencv.git synced 2026-07-21 19:33:03 +04:00

feat: G-API: Custom stream sources in Python (#27276)

Implemented:

- A C++ proxy class PythonCustomStreamSource that implements the
  IStreamSource interface. This class acts as a bridge between
  G-API’s internal streaming engine and user-defined Python
  objects. Internally, it stores a reference to a Python object
  (PyObject*) and is responsible for: calling the Python object’s
  pull() method to retrieve the next frame, calling the descr_of()
  method to obtain the frame format description, acquiring and
  releasing the Python GIL as needed, converting the returned
  numpy.ndarray into cv::Mat, and handling any exceptions or
  conversion errors with proper diagnostics.

- A Python-facing factory function, cv.gapi.wip.make_py_src(),
  which takes a Python object as an argument and wraps it into a
  cv::Ptr<IStreamSource>. Internally, this function constructs a
  PythonCustomStreamSource instance and passes the Python object to
  it. This design allows Python users to define any class that
  implements two methods: pull() and descr_of(). No subclassing or
  special decorators are required on the Python side. The user
  simply needs to implement the expected interface.

Co-authored-by: Leonor Francisco <leonor.francisco@tecnico.ulisboa.pt>
This commit is contained in:
Rita Melo
2025-06-05 18:14:56 +01:00
parent 868fc5c581
commit 439b6af379
5 changed files with 272 additions and 0 deletions
+4
View File
@@ -66,6 +66,7 @@ file(GLOB gapi_ext_hdrs
"${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/streaming/onevpl/*.hpp" "${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/streaming/onevpl/*.hpp"
"${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/plaidml/*.hpp" "${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/plaidml/*.hpp"
"${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/util/*.hpp" "${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/util/*.hpp"
"${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/pysrc/*.hpp"
) )
set(gapi_srcs set(gapi_srcs
@@ -242,6 +243,9 @@ set(gapi_srcs
src/streaming/gstreamer/gstreamer_media_adapter.cpp src/streaming/gstreamer/gstreamer_media_adapter.cpp
src/streaming/gstreamer/gstreamerenv.cpp src/streaming/gstreamer/gstreamerenv.cpp
# Python Custom Stream source
src/pysrc/python_stream_source.cpp
# Utils (ITT tracing) # Utils (ITT tracing)
src/utils/itt.cpp src/utils/itt.cpp
) )
@@ -0,0 +1,65 @@
#ifndef OPENCV_GAPI_PYSRC_PYTHONSTREAMSOURCE_HPP
#define OPENCV_GAPI_PYSRC_PYTHONSTREAMSOURCE_HPP
#include <opencv2/gapi/streaming/source.hpp>
#include <opencv2/core.hpp>
namespace cv {
namespace gapi {
namespace wip {
/**
* @brief Creates a G-API IStreamSource that delegates to a Python-defined source.
*
* This factory function wraps a Python object (for example, an instance of a class
* implementing a `pull()` and a `descr_of()` method) into a `cv::gapi::wip::IStreamSource`,
* enabling it to be used within a G-API computation graph. The OpenCV Python bindings
* automatically convert the PyObject into a `cv::Ptr<IStreamSource>`.
*
* @param src
* A `cv::Ptr<IStreamSource>` that internally holds the original Python object.
*
* @return
* A `cv::Ptr<IStreamSource>` that wraps the provided Python object. On each frame pull,
* G-API will:
* - Acquire the Python GIL
* - Call the Python objects `pull()` method
* - Convert the resulting NumPy array to a `cv::Mat`
* - Pass the `cv::Mat` into the G-API pipeline
*
* @note
* In Python, you can use the returned `make_py_src` as follows:
*
* @code{.py}
* class MyClass:
* def __init__(self):
* # Initialize your source
* def pull(self):
* # Return the next frame as a numpy.ndarray or None for end-of-stream
* def descr_of(self):
* # Return a numpy.ndarray that describes the format of the frames
*
* # Create a G-API source from a Python class
* py_src = cv.gapi.wip.make_py_src(MyClass())
*
* # Define a simple graph: input → copy → output
* g_in = cv.GMat()
* g_out = cv.gapi.copy(g_in)
* graph = cv.GComputation(g_in, g_out)
*
* # Compile the pipeline for streaming and assign the source
* pipeline = graph.compileStreaming()
* pipeline.setSource([py_src])
* pipeline.start()
* @endcode
*/
CV_EXPORTS_W cv::Ptr<cv::gapi::wip::IStreamSource>
make_py_src(const cv::Ptr<cv::gapi::wip::IStreamSource>& src);
} // namespace wip
} // namespace gapi
} // namespace cv
#endif // OPENCV_GAPI_PYSRC_PYTHONSTREAMSOURCE_HPP
+155
View File
@@ -1162,6 +1162,161 @@ bool pyopencv_to(PyObject* obj, cv::GProtoOutputArgs& value, const ArgInfo& info
} }
} }
namespace cv {
namespace gapi {
namespace wip {
/**
* @class PythonCustomStreamSource
* @brief Wraps a Python-defined frame source as an IStreamSource for G-API.
*
* This class allows a G-API pipeline to pull frames from a Python object. The Python object
* must implement at a `pull()` method which must return a `numpy.ndarray` containing
* the next frame, or `None` to signal end-of-stream. It can also implement a `descr_of()`
* method which must return a `numpy.ndarray` that describes the format
* (data type, number of channels, height, width) of the frames produced.
*/
class PythonCustomStreamSource : public IStreamSource
{
public:
PythonCustomStreamSource(PyObject* _obj = nullptr) : obj(_obj)
{
if (obj)
Py_INCREF(obj);
}
~PythonCustomStreamSource()
{
if (obj)
Py_DECREF(obj);
}
bool pull(cv::gapi::wip::Data& data) CV_OVERRIDE
{
if (!obj)
return false;
PyObject* src = reinterpret_cast<PyObject*>(obj);
PyGILState_STATE gstate;
gstate = PyGILState_Ensure();
PyObject* result = PyObject_CallMethodObjArgs(src, PyUnicode_FromString("pull"), NULL);
bool hasPyPullError = PyErr_Occurred() != nullptr;
if (!result)
{
PyErr_Print();
PyGILState_Release(gstate);
CV_Error(cv::Error::StsError, "PythonCustomStreamSource::pull(): call to .pull() failed");
}
if (result == Py_None)
{
Py_DECREF(result);
PyGILState_Release(gstate);
return false;
}
if (!PyArray_Check(result))
{
PyErr_Format(PyExc_TypeError, "Expected numpy.ndarray from .pull()");
PyErr_Print();
Py_DECREF(result);
PyGILState_Release(gstate);
CV_Error(cv::Error::StsError, "PythonCustomStreamSource::pull(): .pull() did not return a numpy.ndarray");
}
cv::Mat mat;
ArgInfo info("pull return", 0);
if (!pyopencv_to(result, mat, info) || PyErr_Occurred())
{
PyErr_Print();
Py_DECREF(result);
PyGILState_Release(gstate);
CV_Error(cv::Error::StsError, "PythonCustomStreamSource::pull(): failed to convert numpy to cv::Mat");
}
if (mat.empty())
{
Py_DECREF(result);
PyGILState_Release(gstate);
return false;
}
data = mat;
Py_DECREF(result);
PyGILState_Release(gstate);
if (hasPyPullError)
CV_Error(cv::Error::StsError, "Python .pull() call error");
return true;
}
GMetaArg descr_of() const CV_OVERRIDE
{
if (!obj)
return cv::GMetaArg(cv::GFrameDesc{cv::MediaFormat::BGR, cv::Size(640, 480)});
PyGILState_STATE gstate = PyGILState_Ensure();
PyObject* result = PyObject_CallMethodObjArgs(obj, PyUnicode_FromString("descr_of"), NULL);
if (!result)
{
PyErr_Print();
PyGILState_Release(gstate);
CV_Error(cv::Error::StsError, "PythonCustomStreamSource::descr_of(): conversion error");
}
if (!PyArray_Check(result)) {
PyErr_Format(PyExc_TypeError, "Expected numpy.ndarray from .descr_of()");
PyErr_Print();
Py_DECREF(result);
PyGILState_Release(gstate);
CV_Error(cv::Error::StsError, "PythonCustomStreamSource::descr_of(): did not return a numpy.ndarray");
}
cv::Mat mat;
ArgInfo info("descr_of return", 0);
if (!pyopencv_to(result, mat, info))
{
PyErr_Print();
Py_DECREF(result);
PyGILState_Release(gstate);
CV_Error(cv::Error::StsError, "PythonCustomStreamSource::descr_of(): conversion error");
}
Py_DECREF(result);
PyGILState_Release(gstate);
cv::GMatDesc mdesc = cv::descr_of(mat);
return cv::GMetaArg(mdesc);
}
private:
PyObject* obj;
};
inline cv::Ptr<IStreamSource> make_pysrc_from_pyobject(PyObject* obj)
{
return cv::makePtr<PythonCustomStreamSource>(obj);
}
} // namespace wip
} // namespace gapi
} // namespace cv
template<>
bool pyopencv_to(PyObject* obj, cv::Ptr<cv::gapi::wip::IStreamSource>& p, const ArgInfo&)
{
if (!obj)
return false;
p = cv::makePtr<cv::gapi::wip::PythonCustomStreamSource>(obj);
return true;
}
// extend cv.gapi methods // extend cv.gapi methods
#define PYOPENCV_EXTRA_METHODS_GAPI \ #define PYOPENCV_EXTRA_METHODS_GAPI \
{"kernels", CV_PY_FN_WITH_KW(pyopencv_cv_gapi_kernels), "kernels(...) -> GKernelPackage"}, \ {"kernels", CV_PY_FN_WITH_KW(pyopencv_cv_gapi_kernels), "kernels(...) -> GKernelPackage"}, \
@@ -539,7 +539,38 @@ try:
self.assertEqual(0.0, cv.norm(convertNV12p2BGR(expected1), actual1, cv.NORM_INF)) self.assertEqual(0.0, cv.norm(convertNV12p2BGR(expected1), actual1, cv.NORM_INF))
self.assertEqual(0.0, cv.norm(convertNV12p2BGR(expected2), actual2, cv.NORM_INF)) self.assertEqual(0.0, cv.norm(convertNV12p2BGR(expected2), actual2, cv.NORM_INF))
def test_python_custom_stream_source(self):
class MySource:
def __init__(self):
self.count = 0
def pull(self):
if self.count >= 3:
return None
self.count += 1
return np.ones((10, 10, 3), np.uint8) * self.count
def descr_of(self):
return np.zeros((10, 10, 3), np.uint8)
g_in = cv.GMat()
g_out = cv.gapi.copy(g_in)
c = cv.GComputation(g_in, g_out)
comp = c.compileStreaming()
src = cv.gapi.wip.make_py_src(MySource())
comp.setSource([src])
comp.start()
frames = []
while True:
has_frame, frame = comp.pull()
if not has_frame:
break
frames.append(frame)
self.assertEqual(len(frames), 3)
except unittest.SkipTest as e: except unittest.SkipTest as e:
@@ -0,0 +1,17 @@
#include <opencv2/gapi/pysrc/python_stream_source.hpp>
#include <opencv2/gapi/streaming/source.hpp>
#include <opencv2/core/utils/logger.hpp>
#include <opencv2/core.hpp>
namespace cv {
namespace gapi {
namespace wip {
cv::Ptr<cv::gapi::wip::IStreamSource> make_py_src(const cv::Ptr<cv::gapi::wip::IStreamSource>& src)
{
return src;
}
} // namespace wip
} // namespace gapi
} // namespace cv