diff --git a/modules/gapi/CMakeLists.txt b/modules/gapi/CMakeLists.txt index f18290ca7d..2a8d9185c0 100644 --- a/modules/gapi/CMakeLists.txt +++ b/modules/gapi/CMakeLists.txt @@ -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}/plaidml/*.hpp" "${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/util/*.hpp" + "${CMAKE_CURRENT_LIST_DIR}/include/opencv2/${name}/pysrc/*.hpp" ) set(gapi_srcs @@ -242,6 +243,9 @@ set(gapi_srcs src/streaming/gstreamer/gstreamer_media_adapter.cpp src/streaming/gstreamer/gstreamerenv.cpp + # Python Custom Stream source + src/pysrc/python_stream_source.cpp + # Utils (ITT tracing) src/utils/itt.cpp ) diff --git a/modules/gapi/include/opencv2/gapi/pysrc/python_stream_source.hpp b/modules/gapi/include/opencv2/gapi/pysrc/python_stream_source.hpp new file mode 100644 index 0000000000..b43dea5305 --- /dev/null +++ b/modules/gapi/include/opencv2/gapi/pysrc/python_stream_source.hpp @@ -0,0 +1,65 @@ +#ifndef OPENCV_GAPI_PYSRC_PYTHONSTREAMSOURCE_HPP +#define OPENCV_GAPI_PYSRC_PYTHONSTREAMSOURCE_HPP +#include +#include + +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`. + * + * @param src + * A `cv::Ptr` that internally holds the original Python object. + * + * @return + * A `cv::Ptr` that wraps the provided Python object. On each frame pull, + * G-API will: + * - Acquire the Python GIL + * - Call the Python object’s `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 +make_py_src(const cv::Ptr& src); + + +} // namespace wip +} // namespace gapi +} // namespace cv + + +#endif // OPENCV_GAPI_PYSRC_PYTHONSTREAMSOURCE_HPP diff --git a/modules/gapi/misc/python/pyopencv_gapi.hpp b/modules/gapi/misc/python/pyopencv_gapi.hpp index 66c3910756..6dcf3081e3 100644 --- a/modules/gapi/misc/python/pyopencv_gapi.hpp +++ b/modules/gapi/misc/python/pyopencv_gapi.hpp @@ -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(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 make_pysrc_from_pyobject(PyObject* obj) +{ + return cv::makePtr(obj); +} + +} // namespace wip +} // namespace gapi +} // namespace cv + +template<> +bool pyopencv_to(PyObject* obj, cv::Ptr& p, const ArgInfo&) +{ + if (!obj) + return false; + + p = cv::makePtr(obj); + return true; +} + // extend cv.gapi methods #define PYOPENCV_EXTRA_METHODS_GAPI \ {"kernels", CV_PY_FN_WITH_KW(pyopencv_cv_gapi_kernels), "kernels(...) -> GKernelPackage"}, \ diff --git a/modules/gapi/misc/python/test/test_gapi_streaming.py b/modules/gapi/misc/python/test/test_gapi_streaming.py index 7f9de5f767..2d3f8f2785 100644 --- a/modules/gapi/misc/python/test/test_gapi_streaming.py +++ b/modules/gapi/misc/python/test/test_gapi_streaming.py @@ -539,7 +539,38 @@ try: self.assertEqual(0.0, cv.norm(convertNV12p2BGR(expected1), actual1, 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: diff --git a/modules/gapi/src/pysrc/python_stream_source.cpp b/modules/gapi/src/pysrc/python_stream_source.cpp new file mode 100644 index 0000000000..6ede2ae201 --- /dev/null +++ b/modules/gapi/src/pysrc/python_stream_source.cpp @@ -0,0 +1,17 @@ +#include +#include +#include +#include + +namespace cv { +namespace gapi { +namespace wip { + +cv::Ptr make_py_src(const cv::Ptr& src) +{ + return src; +} + +} // namespace wip +} // namespace gapi +} // namespace cv