diff --git a/doc/tutorials/core/file_input_output_with_xml_yml/file_input_output_with_xml_yml.markdown b/doc/tutorials/core/file_input_output_with_xml_yml/file_input_output_with_xml_yml.markdown index 7f238007eb..416f8f9108 100644 --- a/doc/tutorials/core/file_input_output_with_xml_yml/file_input_output_with_xml_yml.markdown +++ b/doc/tutorials/core/file_input_output_with_xml_yml/file_input_output_with_xml_yml.markdown @@ -96,7 +96,7 @@ you may access it. For sequences you need to go through them to query a specific @snippet cpp/tutorial_code/core/file_input_output/file_input_output.cpp readNum @end_toggle @add_toggle_python - @snippet cpp/tutorial_code/core/file_input_output/file_input_output.cpp readNum + @snippet python/tutorial_code/core/file_input_output/file_input_output.py readNum @end_toggle -# **Input/Output of OpenCV Data structures.** Well these behave exactly just as the basic C++ and Python types: diff --git a/doc/tutorials/core/univ_intrin/univ_intrin.markdown b/doc/tutorials/core/univ_intrin/univ_intrin.markdown index b38a8c3d2f..f09fe0a336 100644 --- a/doc/tutorials/core/univ_intrin/univ_intrin.markdown +++ b/doc/tutorials/core/univ_intrin/univ_intrin.markdown @@ -44,6 +44,9 @@ Supported SIMD/VLA technologies are detailed in @ref core_hal_intrin . * Load and store * Mathematical Operations * Reduce and Mask +* 16-bit float arithmetic (FP16 and BF16) +* Math function intrinsics +* Multi-channel operations ### Register Structures @@ -76,7 +79,10 @@ There are **two types** of registers: |-:|:-| |uint| 8, 16, 32, 64| |int | 8, 16, 32, 64| - |float | 32, 64| + |float | 16, 32, 64| + |bfloat | 16| + + @note The 16-bit floating-point types (`v_float16`, `v_bfloat16`) require their SIMD guards to be enabled - see the [FP16 and BF16 Arithmetic](#fp16-and-bf16-arithmetic) section below. * **Constant sized registers**: These structures have a fixed bit size and hold a constant number of values. We need to know what SIMD instruction set is supported by the system and select compatible registers. Use these only if exact bit length is necessary.
@@ -116,7 +122,6 @@ Now that we know how registers work, let us look at the functions used for filli // Or we can explicitly write down the values. v_float32x4(1, 2, 3, 4); -
* *Load Function* - We can use the load method and provide the memory address of the data: float ptr[32] = {1, 2, 3, ..., 32}; @@ -238,6 +243,187 @@ See also: https://github.com/opencv/opencv/issues/27267 It is common to set all values of b to 0. Thus, v_select will give values of "a" or 0 based on the mask. */ +FP16 and BF16 Arithmetic {#fp16-and-bf16-arithmetic} +------------------------ + +OpenCV 5.0 introduces universal intrinsic support for 16-bit floating-point types: FP16 (`cv::hfloat`, `CV_16F`) and BF16 (`cv::bfloat`, `CV_16BF`). These are guarded by two preprocessor flags that mirror the existing `CV_SIMD_64F` / `CV_SIMD_SCALABLE_64F` guards for double-precision support. + +| Flag | Description | +|---|---| +| `CV_SIMD_16F` | Fixed-width SIMD targets where native FP16 arithmetic is available (e.g. ARMv8.2+ NEON, RISC-V RVV). | +| `CV_SIMD_SCALABLE_16F` | VLA targets (SVE, RVV) where the register width is determined at runtime. | + +Code written against these guards is forward-compatible. As x86 and other architectures add native FP16/BF16 arithmetic instructions in future hardware generations, the same source will automatically benefit without modification. + +### Basic Usage + +To operate on 16-bit floating-point types, utilize the `vx_load_f16`, `v_add`, and `vx_store_f16` APIs. + +@code{.cpp} +#if CV_SIMD_16F || CV_SIMD_SCALABLE_16F +// Load FP16 values from memory into a v_float16 register +v_float16 a = vx_load_f16(src1_ptr); +v_float16 b = vx_load_f16(src2_ptr); + +// Element-wise addition and fused multiply-add +v_float16 c = v_add(a, b); +v_float16 d = v_fma(a, b, c); // a*b + c + +// Store result back to FP16 memory +vx_store_f16(dst_ptr, d); +#endif +@endcode + +### Expanding and Packing + +To perform intermediate computation in higher precision (FP32), the universal intrinsics provide `v_expand` and `v_pack`. Expanding converts a single 16-bit register into two 32-bit registers, and packing reverses the operation. + +@code{.cpp} +#if CV_SIMD_16F || CV_SIMD_SCALABLE_16F +v_float16 val_16 = vx_load_f16(src_ptr); + +// Widen to FP32 for mixed-precision computation +v_float32 lo, hi; +v_expand(val_16, lo, hi); + +// Intermediate computation on FP32 registers +v_float32 res_lo = v_mul(lo, vx_setall_f32(3.14159f)); +v_float32 res_hi = v_mul(hi, vx_setall_f32(3.14159f)); + +// Pack back to FP16 for storage +v_float16 res_16 = v_pack(res_lo, res_hi); +vx_store_f16(dst_ptr, res_16); +#endif +@endcode + +### BF16 Load/Store and Mixed Precision + +BF16 shares its exponent range with FP32, making it critical for deep learning inference. Conversion between BF16 and FP32 via expand/pack is highly efficient and executes primarily as a bit-shift. To handle BF16 data, use the specific `v_bfloat16` type alongside `vx_load_bf16` and `vx_store_bf16`. + +@code{.cpp} +#if CV_SIMD_16BF || CV_SIMD_SCALABLE_16BF +// Load BF16 values from memory +v_bfloat16 b_val = vx_load_bf16(bf16_ptr); + +// Widen to FP32 for high-precision deep learning accumulation +v_float32 b_lo, b_hi; +v_expand(b_val, b_lo, b_hi); + +// Computation in FP32 +v_float32 res_lo = v_add(b_lo, vx_setall_f32(1.0f)); +v_float32 res_hi = v_add(b_hi, vx_setall_f32(1.0f)); + +// Pack back to BF16 for output +v_bfloat16 b_res = v_pack_b(res_lo, res_hi); +vx_store_bf16(dst_bf16_ptr, b_res); +#endif +@endcode + +### Integer Dot Products (Quantization) + +To support the optimized DNN module in OpenCV 5.0, universal intrinsics now include fast 8-bit integer dot products. These are essential for writing custom layers for quantized neural networks. Architectures like ARMv8.4+ and AVX-VNNI have dedicated hardware instructions to multiply 8-bit integers and accumulate them into 32-bit integers in a single pass. + +@code{.cpp} +v_int8 vec_a = vx_load(int8_src1); +v_int8 vec_b = vx_load(int8_src2); +v_int32 acc = vx_setzero_s32(); + +// Multiply 8-bit integers and accumulate into 32-bit registers +acc = v_dotprod_int8(vec_a, vec_b, acc); +@endcode + +### Build Requirements + +FP16 SIMD arithmetic and scalable vectors are not available by default. The required targets must be enabled at build time. OpenCV's runtime dispatcher will select the appropriate kernel based on the actual CPU capabilities at launch. + +@code{.sh} +# ARM - enable ARMv8.2+ FP16 extensions +cmake -DCPU_BASELINE=NEON -DCPU_DISPATCH=FP16 .. + +# RISC-V - enable RVV vector extension +cmake -DRISCV_RVV_SCALABLE=ON .. + +# WebAssembly (WASM) - enable 128-bit SIMD for browser execution +cmake -DCMAKE_TOOLCHAIN_FILE=../platforms/js/build_wasm.sh -DENABLE_WASM_SIMD=ON .. +@endcode + +@note On platforms where `CV_SIMD_16F` is not defined, FP16 intrinsics are not compiled in. Any code that calls them must be wrapped in the corresponding `#if` guard. + +### Dynamic Dispatch & Lane Counting + +When compiling for Vector Length Agnostic (VLA) architectures (guarded by `CV_SIMD_SCALABLE_*`), the size of the SIMD register is unknown at compile time. It is determined dynamically by the hardware at runtime. + +Because of this, you cannot hardcode loop increments (e.g., `i += 4` or `i += 8`). You must use OpenCV's lane-counting macros to advance memory pointers safely. + +@code{.cpp} +// Correct approach for scalable loops +int step = VTraits::vlanes(); // Dynamic step size +for (int i = 0; i <= length - step; i += step) { + v_float32 v = vx_load(src + i); + // ... +} +@endcode + +Math Function Intrinsics +------------------------ + +Vectorized implementations of common mathematical functions were added in OpenCV 5.0 (and backported to 4.x). They operate element-wise on `v_float32` and `v_float64` registers. Generic implementations cover all platforms, with hardware-specific fast paths mapped where supported by the architecture. + +### Supported Operations + +| Function | Operation | Notes | +|---|---|---| +| `v_exp(x)` | \f$e^x\f$ | | +| `v_log(x)` | \f$\ln(x)\f$ | Requires \f$x > 0\f$ | +| `v_erf(x)` | Gauss error function | Utilized in GELU activation kernels | +| `v_sincos(x, s, c)` | Sine and cosine | Simultaneous calculation, more efficient than separate calls | +| `v_pow(x, y)` | \f$x^y\f$ | | +| `v_tanh(x)` | Hyperbolic tangent | Utilized in activation kernels | +| `v_atan2(y, x)` | Arctangent of \f$y/x\f$ | Result in radians | + +### Basic Usage + +@code{.cpp} +v_float32 x = vx_load(src_ptr); + +v_float32 e = v_exp(x); // e^x for each lane +v_float32 l = v_log(x); // ln(x) for each lane, x > 0 +v_float32 err = v_erf(x); // Gauss error function + +v_float32 s, c; +v_sincos(x, s, c); // sine and cosine in one pass + +// Sigmoid: 1 / (1 + e^{-x}) +v_float32 ones = vx_setall_f32(1.f); +v_float32 neg_x = v_mul(x, vx_setall_f32(-1.f)); +v_float32 sig = v_div(ones, v_add(ones, v_exp(neg_x))); +@endcode + +@note Math intrinsics do not require guard macros. Generic scalar-loop fallback implementations ensure code compilation across all target platforms. + +Multi-Channel Data Operations +----------------------------- + +When vectorizing operations on multi-channel data types (e.g., interleaved 3-channel BGR images), standard load functions such as `vx_load` will capture adjacent channel values into the same register. Element-wise arithmetic operations on such a register will yield incorrect scalar results across the respective channels. + +To process multi-channel arrays, use the `v_load_deinterleave` and `v_store_interleave` APIs to distribute channel values into independent registers. + +### Basic Usage + +@code{.cpp} +// 3-channel deinterleave +v_float32 b, g, r; +v_load_deinterleave(src_ptr, b, g, r); + +// Apply independent scalar multipliers per channel +v_float32 y = v_mul(b, vx_setall_f32(0.114f)); +y = v_fma(g, vx_setall_f32(0.587f), y); +y = v_fma(r, vx_setall_f32(0.299f), y); + +// Interleave registers back to target address +v_store_interleave(dst_ptr, y, y, y); +@endcode + ## Demonstration In the following section, we will vectorize a simple convolution function for single channel and compare the results to a scalar implementation. @note Not all algorithms are improved by manual vectorization. In fact, in certain cases, the compiler may *autovectorize* the code, thus producing faster results for scalar implementations. diff --git a/doc/tutorials/geometry/convex_hull/convex_hull.markdown b/doc/tutorials/geometry/convex_hull/convex_hull.markdown new file mode 100644 index 0000000000..5f3a0ab06f --- /dev/null +++ b/doc/tutorials/geometry/convex_hull/convex_hull.markdown @@ -0,0 +1,98 @@ +Convex Hull {#tutorial_geometry_convex_hull} +=========== + +@tableofcontents + +| | | +| -: | :- | +| Compatibility | OpenCV >= 5.0 | + +Goal +---- + +In this tutorial you will learn how to: + +- Use the OpenCV function @ref cv::convexHull (part of the **geometry** module in OpenCV 5.0) + to find the convex hull of a 2D point set or contour. +- Link the new `opencv_geometry` module in your CMake project. + +Theory +------ + +The convex hull of a set of points is the smallest convex polygon that contains all the +points. To visualize this, imagine a rubber band stretched open to encompass all the given +points; when released, it snaps around the outermost points, taking the shape of the convex +hull. + +### The algorithm + +OpenCV computes the hull using Sklansky's algorithm, which is highly efficient for 2D points: + +- If the points are unsorted, the time complexity is \f$O(N \log N)\f$. +- If the points are already sorted (for example, ordered contour points from + @ref cv::findContours), the complexity drops to \f$O(N)\f$. + +@note In OpenCV 5.0 the computational-geometry algorithms were reorganized: functions such as +@ref cv::convexHull moved from `imgproc` to the new **geometry** module. C++ code must now +include ``. + +Project configuration (CMake) +----------------------------- + +Because OpenCV 5.0 moved these functions into a dedicated module, link `opencv_geometry` in +your `CMakeLists.txt` alongside the standard modules: + +@code{.cmake} +cmake_minimum_required(VERSION 3.1) +project(ConvexHullDemo) + +find_package(OpenCV REQUIRED) + +add_executable(ConvexHullDemo convex_hull_demo.cpp) +target_link_libraries(ConvexHullDemo ${OpenCV_LIBS} opencv_geometry) +@endcode + +Code +---- + +@add_toggle_cpp +This tutorial code's is shown lines below. You can also download it from +[here](https://github.com/opencv/opencv/tree/5.x/samples/cpp/tutorial_code/ShapeDescriptors/hull_demo.cpp) +@include samples/cpp/tutorial_code/ShapeDescriptors/hull_demo.cpp +@end_toggle + +@add_toggle_java +This tutorial code's is shown lines below. You can also download it from +[here](https://github.com/opencv/opencv/tree/5.x/samples/java/tutorial_code/ShapeDescriptors/hull/HullDemo.java) +@include samples/java/tutorial_code/ShapeDescriptors/hull/HullDemo.java +@end_toggle + +@add_toggle_python +This tutorial code's is shown lines below. You can also download it from +[here](https://github.com/opencv/opencv/tree/5.x/samples/python/tutorial_code/ShapeDescriptors/hull/hull_demo.py) +@include samples/python/tutorial_code/ShapeDescriptors/hull/hull_demo.py +@end_toggle + +Explanation +----------- + +- **Include the headers.** The demo includes `imgproc.hpp` for edge and contour detection + and the new `geometry.hpp` for the convex-hull algorithm. +- **Edge detection and contour extraction.** Inside the trackbar callback the Canny edge + detector finds the raw edges, and @ref cv::findContours retrieves the boundary shapes. +- **Compute the convex hull.** For each contour, @ref cv::convexHull computes its convex + polygon. By default it takes the input point set and outputs the hull coordinates. +- **Draw the results.** Both the original contours and their bounding hulls are drawn with + @ref cv::drawContours on a blank image, each contour/hull pair sharing a random color. + +@note `cv::convexHull(points, hull, clockwise, returnPoints)` accepts two useful flags. +`clockwise` orders the output points clockwise when `true` (default `false`). `returnPoints` +returns hull coordinates when `true` (default); set it to `false` to get the **indices** of +the original points instead — required if you later call @ref cv::convexityDefects. + +Result +------ + +After compiling and running with an input image, a window with a trackbar appears. As you +adjust the Canny threshold, the contours are recomputed dynamically and drawn alongside their +convex hulls, each matching pair sharing the same randomized color. diff --git a/doc/tutorials/geometry/table_of_content_geometry.markdown b/doc/tutorials/geometry/table_of_content_geometry.markdown index 2cacca0ef1..6fff329ad9 100644 --- a/doc/tutorials/geometry/table_of_content_geometry.markdown +++ b/doc/tutorials/geometry/table_of_content_geometry.markdown @@ -1,3 +1,5 @@ Computational geometry module {#tutorial_table_of_content_geometry} ========================================================== + +- @subpage tutorial_geometry_convex_hull diff --git a/doc/tutorials/introduction/transition_guide/transition_guide.markdown b/doc/tutorials/introduction/transition_guide/transition_guide.markdown index ef5d07a395..6a92b14ca5 100644 --- a/doc/tutorials/introduction/transition_guide/transition_guide.markdown +++ b/doc/tutorials/introduction/transition_guide/transition_guide.markdown @@ -14,4 +14,5 @@ Changes overview {#tutorial_transition_overview} ================ This document is intended to software developers who want to migrate their code to OpenCV 5.0. -**TODO** +For the full list of breaking changes and porting notes, see +[Learn more](https://github.com/opencv/opencv/wiki/OpenCV-4-to-5-migration). diff --git a/doc/tutorials/introduction/using_prebuilt_binaries/using_prebuilt_binaries.markdown b/doc/tutorials/introduction/using_prebuilt_binaries/using_prebuilt_binaries.markdown new file mode 100644 index 0000000000..f47ae6ac8a --- /dev/null +++ b/doc/tutorials/introduction/using_prebuilt_binaries/using_prebuilt_binaries.markdown @@ -0,0 +1,147 @@ +Using OpenCV pre-built binaries in your own projects {#tutorial_using_prebuilt_binaries} +==================================================== + +| | | +| -: | :- | +| Original authors | Abhishek Gola & Kirti Jindal | +| Compatibility | OpenCV >= 5.0, C++17, Python >= 3.6 | + +@tableofcontents + +Goal +---- + +The objective of this tutorial is to show how to configure a local build environment to use +pre-built OpenCV 5.0 binaries that are already present on your device. + +By the end of this guide you will know how to reference, link, and use an existing OpenCV +installation from your own C++ or Python application, without rebuilding the library from +source. + +Detailed Description +-------------------- + +When OpenCV is installed via an installer, a system package manager (apt, Homebrew, vcpkg), +or built into a local workspace directory, it exports configuration scripts that let build +tools locate and link it automatically. + +Because OpenCV 5.0 modernizes its build requirements, keep two things in mind: + +- **C++17 is required.** The library headers use modern language features, so your project + must be compiled with the C++17 standard or higher. +- **Modern CMake targets.** The legacy 1.x C API has been removed. Link via the + `${OpenCV_LIBS}` variable from `find_package(OpenCV)`, or by naming the specific + libraries on the compiler command line. + +C++ Project Configuration +------------------------- + +You can link against your pre-built binaries with CMake (recommended for cross-platform +stability) or by invoking g++ directly. + +### Method 1: Using CMake (recommended) + +#### 1. File layout + +@code{.unparsed} +my_opencv_project/ +├── CMakeLists.txt +└── main.cpp +@endcode + +#### 2. CMakeLists.txt + +@code{.cmake} +cmake_minimum_required(VERSION 3.22) +project(OpenCV5_Local_Project) + +# OpenCV 5.0 requires C++17 +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Locate the pre-built OpenCV installation. If CMake cannot find it +# automatically, pass the path explicitly: +# cmake -DOpenCV_DIR=/absolute/path/to/opencv/build/ .. +find_package(OpenCV 5.0 REQUIRED) + +message(STATUS "Found OpenCV version: ${OpenCV_VERSION}") +message(STATUS "OpenCV include directories: ${OpenCV_INCLUDE_DIRS}") + +add_executable(opencv_test_app main.cpp) +target_link_libraries(opencv_test_app PRIVATE ${OpenCV_LIBS}) +@endcode + +#### 3. Build and run + +@code{.bash} +cd path/to/my_opencv_project +mkdir build && cd build +cmake .. +cmake --build . +./opencv_test_app +@endcode + +### Method 2: Direct compilation with g++ + +On Linux or macOS you can compile with a single command, without generating build files. + +#### 1. Locate your paths + +- Standard global location: `/usr/local/include/opencv5` and `/usr/local/lib` +- Custom build location: `/path/to/opencv/build/include` and `/path/to/opencv/build/lib` + +#### 2. Using pkg-config + +@code{.bash} +g++ -std=c++17 main.cpp -o opencv_test_app $(pkg-config --cflags --libs opencv5) +@endcode + +#### 3. Explicit paths (custom or local builds) + +@code{.bash} +g++ -std=c++17 main.cpp -o opencv_test_app \ + -I/usr/local/include/opencv5 \ + -L/usr/local/lib \ + -lopencv_core -lopencv_imgcodecs -lopencv_highgui -lopencv_imgproc +@endcode + +@note In OpenCV 5.0 the legacy `calib3d` module was split into four modules: `geometry`, +`calib`, `stereo`, and `ptcloud`. Link the specific one(s) you need, e.g. `-lopencv_geometry`, +`-lopencv_calib`, `-lopencv_stereo`, or `-lopencv_ptcloud`, instead of `-lopencv_calib3d`. + +Python Project Configuration +---------------------------- + +If your device already has the pre-compiled Python bindings (`cv2`), you can use them directly. + +#### 1. Verify the binding + +If you are using a local or custom build, ensure your system can locate the generated `cv2` +binary by setting the `PYTHONPATH` environment variable: + +@code{.bash} +export PYTHONPATH=/path/to/opencv/build/lib:$PYTHONPATH +@endcode + +@note Replace `/path/to/opencv/build/lib` with the actual path to the directory containing your +compiled `cv2` module (usually inside your local `build/lib` or `build/lib/python3` folder). + +Once the path is set, run the following command to verify that the module imports cleanly and +prints the correct version: + +@code{.bash} +python3 -c "import cv2; print('OpenCV version:', cv2.__version__)" +@endcode + +#### 2. Test script + +Write a short `app.py` that imports `cv2`, loads an image, and displays it to confirm the bindings work. + +Troubleshooting +--------------- + +- **CMake: "Could not find a package configuration file ..."** — CMake cannot locate + the install. Pass the path explicitly: `cmake -DOpenCV_DIR=/path/to/opencv/build/ ..` +- **Compiler: "OpenCV 5.0 requires C++17"** — the toolchain fell back to an older + standard. Add `-std=c++17` to the g++ command, or `set(CMAKE_CXX_STANDARD 17)` before + `find_package` in CMake. diff --git a/docs_sphinx/CMakeLists.txt b/docs_sphinx/CMakeLists.txt index a5960006d1..763a353006 100644 --- a/docs_sphinx/CMakeLists.txt +++ b/docs_sphinx/CMakeLists.txt @@ -199,7 +199,7 @@ set(_SPHINX_WARNINGS "${CMAKE_CURRENT_BINARY_DIR}/sphinx-warnings.log") set(_OPENCV_JS "${CMAKE_CURRENT_BINARY_DIR}/opencv.js") if(NOT EXISTS "${_OPENCV_JS}") message(STATUS "docs_sphinx: downloading opencv.js (one-time)") - file(DOWNLOAD "https://docs.opencv.org/5.x/opencv.js" "${_OPENCV_JS}" + file(DOWNLOAD "https://docs.opencv.org/5.x/js_tutorials/opencv.js" "${_OPENCV_JS}" SHOW_PROGRESS STATUS _DL_STATUS) list(GET _DL_STATUS 0 _DL_OK) if(NOT _DL_OK EQUAL 0) diff --git a/docs_sphinx/_static/custom.css b/docs_sphinx/_static/custom.css index 580207609b..957bf8f575 100644 --- a/docs_sphinx/_static/custom.css +++ b/docs_sphinx/_static/custom.css @@ -67,6 +67,15 @@ html[data-theme="dark"] .bd-article-container li > a:hover { } .bd-content a > code, .bd-content code > a { color: inherit; } +/* Re-assert link colour for xref links inside code — the `color: inherit` + above would otherwise render them in the plain code colour. */ +.bd-content code > a.reference, +.bd-content pre a.reference { + color: var(--pst-color-link, #0969da) !important; +} +.bd-content code > a.reference span, +.bd-content pre a.reference span { color: inherit !important; } + .bd-content a.opencv-enum-link, .bd-content code.opencv-enum-sig > a, .bd-content .opencv-enum-clickable a, @@ -320,6 +329,45 @@ html[data-theme="dark"] table.opencv-meta-table td:last-child { border-right: no .bd-header button.theme-switch-button:hover, .bd-header .navbar-icon-links a.nav-link:hover { color: var(--opencv-accent); } +/* Override the theme's col-lg-3 (25%) width on __start, which would wrap the + version switcher below the logo; size to content and keep the row unwrapped. */ +.bd-header .navbar-header-items__start { width: auto; } +.bd-header .navbar-header-items__start, +.bd-header .navbar-header-items__end, +.bd-header .navbar-header-items__center, +.bd-header .navbar-nav { flex-wrap: nowrap; } + +/* Below 1200px: collapse only the nav links into the drawer; keep search, + theme toggle and GitHub icon in the header (search shrinks to its icon). */ +@media (max-width: 1199.98px) { + .bd-header .navbar-header-items { display: flex !important; flex-grow: 1; } + .bd-header .navbar-header-items__center { display: none !important; } + .bd-header .navbar-header-items__end { margin-left: auto; } + .bd-header button.primary-toggle { display: flex !important; } + .bd-sidebar-primary .sidebar-header-items { display: flex !important; } + .bd-sidebar-primary .sidebar-header-items__end { display: none !important; } + .search-button-field > :not(svg) { display: none !important; } + /* Hide the desktop "Collapse Sidebar" toggle; also keeps it out of the + mobile drawer, which reuses this content. */ + .bd-sidebar-primary .pst-sidebar-collapse { display: none !important; } +} + +/* 960-1200px: header has folded into the hamburger, so extend the theme's own + <960px off-canvas drawer up to the header breakpoint instead of showing a + persistent column (which would mix header links + nav + collapse toggle). */ +@media (min-width: 960px) and (max-width: 1199.98px) { + .bd-sidebar-primary { + position: fixed; top: 0; left: 0; z-index: 1055; + height: 100vh; max-height: 100vh; width: 75%; max-width: 350px; + margin-left: -75%; visibility: hidden; border: 0; flex-grow: 0.75; + } +} + +/* On small screens drop the wordmark + subtitle, keep just the logo mark. */ +@media (max-width: 768px) { + .bd-header .navbar-brand.logo .logo__textwrap { display: none; } +} + /* --- Version switcher dropdown (navbar) -------------------------------- * * Sits in the `navbar_start` slot right after `navbar-logo`. The custom * template `_templates/opencv-version-switcher.html` renders a plain @@ -384,9 +432,14 @@ html[data-theme="dark"] .opencv-version-switcher #opencv-version-select option { the content and the (often wide) diagrams get more room. Desktop only; the mobile off-canvas drawer keeps the theme's own sizing. */ @media (min-width: 960px) { - .bd-sidebar-primary { width: 16rem; flex: 0 0 16rem; } + /* flex:0 0 auto (not a rigid 16rem basis) so the theme's collapse rule + (.pst-squeeze{width:4rem}) can still shrink it when collapsed. */ + .bd-sidebar-primary { width: 16rem; flex: 0 0 auto; } } +/* Collapsed: drop the divider so no leftover vertical border remains. */ +.bd-sidebar-primary.pst-squeeze { border-right: none; } + .bd-sidebar-primary nav.bd-links { margin-right: 0 !important; } .bd-sidebar-primary nav.bd-docs-nav p.bd-links__title { font-size: 0.9rem !important; @@ -422,6 +475,10 @@ html[data-theme="dark"] .opencv-version-switcher #opencv-version-select option { } html[data-theme="dark"] .bd-sidebar-primary nav.bd-links .current > a { color: #539bf5 !important; } +/* Hide the site footer (Sphinx/PyData credit); the prev/next nav + (.prev-next-footer) is a separate element and stays. */ +.bd-footer { display: none !important; } + /* --- Code blocks ------------------------------------------------------- */ div.highlight { position: relative; @@ -662,6 +719,17 @@ html[data-theme="dark"] section[id$="-documentation"] > section:hover { border-color: #58a6ff; box-shadow: none; } +/* Landing page id "opencv-documentation" matches the module card selectors + above; undo the boxing so the landing sections stay flat. */ +#opencv-documentation > section, +html[data-theme="dark"] #opencv-documentation > section { + border: none; + background: none; + box-shadow: none; + margin: 0; +} +#opencv-documentation > section > h2 { margin-bottom: 0.25rem; } +#opencv-documentation > section > ul { margin-top: 0; } section[id$="-documentation"] > section > h3 { display: flex; @@ -1781,3 +1849,21 @@ html[data-theme="dark"] section[id$="-documentation"] code.opencv-param-name { border: 1px solid #444c56 !important; color: #adbac7 !important; } +/* Breathe (namespace pages) renders param names as plain ; box them + to match the code.opencv-param-name chips used on other function pages. */ +dl.field-list dd ul.simple > li > p > strong:first-child { + font-family: var(--pst-font-family-monospace, monospace); + font-weight: 400; + background-color: #eff1f3; + border: 1px solid #d0d7de; + border-radius: 4px; + padding: 0.1em 0.4em; + font-size: 0.85em; + color: #24292f; + white-space: nowrap; +} +html[data-theme="dark"] dl.field-list dd ul.simple > li > p > strong:first-child { + background-color: #2d333b; + border: 1px solid #444c56; + color: #adbac7; +} diff --git a/docs_sphinx/_templates/layout.html b/docs_sphinx/_templates/layout.html index de072a1695..70e04cbc97 100644 --- a/docs_sphinx/_templates/layout.html +++ b/docs_sphinx/_templates/layout.html @@ -12,7 +12,12 @@ // SearchBox lives in search.js; guard so a load failure can't abort this // script (which would leave the modal trigger unwired). var searchBox; - try { searchBox = new SearchBox("searchBox", "{{ _search }}", '.html'); } catch (e) {} + try { + searchBox = new SearchBox("searchBox", "{{ _search }}", '.html'); + // Short debounce: the top-N renderer below makes each search cheap enough + // for live-feeling results. + if (searchBox) searchBox.keyTimeoutLength = 120; + } catch (e) {} // Open/close the centered modal. Defined early so the Ctrl+K handler can use // them, and kept independent of the Doxygen backend so opening always works. @@ -40,6 +45,22 @@ }, true); document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll(".bd-links__title").forEach(function (el) { + if (/section navigation/i.test(el.textContent)) el.textContent = "Navigation Bar"; + }); + + // The theme's hamburger only opens the drawer dialog; add toggle-to-close + // and backdrop-click-to-close. + (function () { + var dlg = document.getElementById("pst-primary-sidebar-modal"); + var tog = document.querySelector(".primary-toggle"); + if (!dlg || !tog) return; + tog.addEventListener("click", function (e) { + if (dlg.open) { e.preventDefault(); e.stopImmediatePropagation(); dlg.close(); } + }, true); // capture: pre-empt the theme's open-only handler when already open + dlg.addEventListener("click", function (e) { if (e.target === dlg) dlg.close(); }); + })(); + // The theme renders navbar_end twice (desktop header + mobile drawer), so // the search component appears twice → duplicate #opencvSearchTrigger / // #opencvSearchOverlay IDs. Keep ONE overlay; wire EVERY trigger to it @@ -81,6 +102,54 @@ init_search(); searchBox.OnSelectItem(0); // default to "All" on every page load + // Fast results: render only the top matches from the in-memory index, + // instead of Doxygen building/toggling all ~4000 rows every keystroke. + try { createResults = function () {}; } catch (e) {} + if (typeof searchResults !== "undefined" && searchResults) { + searchResults.Search = function (q) { + q = (q || "").replace(/^ +| +$/g, "").toLowerCase(); + var box = document.getElementById("SRResults"); + if (!box) return true; + box.innerHTML = ""; + var data = (typeof searchData !== "undefined") ? searchData : []; + var rp = searchBox.resultsPath, shown = 0, MAX = 40; + function cls(el, c) { el.setAttribute("class", c); el.setAttribute("className", c); } + for (var i = 0; i < data.length && shown < MAX; i++) { + var el = data[i]; + if ((el[1][0] || "").replace(/<[^>]*>/g, "").toLowerCase().indexOf(q) !== 0) continue; + var r = document.createElement("div"); + r.id = "SR_" + el[0]; cls(r, "SRResult"); r.style.display = "block"; + var e = document.createElement("div"); cls(e, "SREntry"); + var a = document.createElement("a"); a.id = "Item" + shown; cls(a, "SRSymbol"); + a.innerHTML = el[1][0]; e.appendChild(a); + if (el[1].length === 2) { + a.setAttribute("href", rp + el[1][1][0]); + a.setAttribute("onclick", "searchBox.CloseResultsWindow()"); + a.setAttribute("target", el[1][1][1] ? "_parent" : "_blank"); + var sp = document.createElement("span"); cls(sp, "SRScope"); + sp.innerHTML = el[1][1][2] || ""; e.appendChild(sp); + } else { + a.setAttribute("href", 'javascript:searchResults.Toggle("SR_' + el[0] + '")'); + var chn = document.createElement("div"); cls(chn, "SRChildren"); + for (var c = 0; c < el[1].length - 1; c++) { + var ca = document.createElement("a"); cls(ca, "SRScope"); + ca.setAttribute("href", rp + el[1][c + 1][0]); + ca.setAttribute("onclick", "searchBox.CloseResultsWindow()"); + ca.setAttribute("target", el[1][c + 1][1] ? "_parent" : "_blank"); + ca.innerHTML = el[1][c + 1][2] || ""; chn.appendChild(ca); + } + e.appendChild(chn); + } + r.appendChild(e); box.appendChild(r); shown++; + } + var nm = document.getElementById("NoMatches"); if (nm) nm.style.display = shown ? "none" : "block"; + var ld = document.getElementById("Loading"); if (ld) ld.style.display = "none"; + var sg = document.getElementById("Searching"); if (sg) sg.style.display = "none"; + searchResults.lastMatchCount = shown; + return true; + }; + } + var sphinxRoot = "{{ '../' * (pagename or '').count('/') }}"; // Doxygen result href -> Sphinx page, or "" if that page is not in our build. function resolveSphinx(href) { @@ -106,13 +175,16 @@ if (sphinxPath) { e.preventDefault(); e.stopPropagation(); - // Doxygen #autotoc_md anchors don't exist in Sphinx; rebuild from heading text. - var frag = ""; - if (/#autotoc_md/.test(href)) { - var slug = (a.textContent || "").trim().toLowerCase() - .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); - if (slug) frag = "#" + slug; - } + // Doxygen anchors (#ga…, #autotoc_md) don't exist in Sphinx, so + // rebuild the fragment slug from the result text: tutorials use the + // full heading; symbols use the name with scope + "(args)" stripped + // -> e.g. "cv::Canny(...)" -> #canny. + var t = (a.textContent || "").trim(); + var slug = /#autotoc_md/.test(href) + ? t.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") + : t.replace(/.*::/, "").replace(/\s*\(.*$/, "").toLowerCase() + .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + var frag = slug ? "#" + slug : ""; window.location.href = sphinxRoot + sphinxPath + frag; } else if (href.indexOf("doc/doxygen/html/") >= 0) { e.preventDefault(); diff --git a/docs_sphinx/_templates/resources.html b/docs_sphinx/_templates/resources.html new file mode 100644 index 0000000000..fac5200d4e --- /dev/null +++ b/docs_sphinx/_templates/resources.html @@ -0,0 +1,14 @@ +{# Right-sidebar "Resources" box (landing page). Mirrors page-toc.html classes + so it matches "On this page"; `reference external` gets the theme's arrow. #} +
+ {{ _('Resources') }} +
+ diff --git a/docs_sphinx/conf.py b/docs_sphinx/conf.py index 70a01589fe..ed00c1de56 100644 --- a/docs_sphinx/conf.py +++ b/docs_sphinx/conf.py @@ -169,9 +169,12 @@ html_theme_options = { "show_toc_level": 2, "navigation_with_keys": True, "show_prev_next": True, - "show_nav_level": 2, + # 1, not 2: the theme force-opens every
up to this level and + # modules render at toctree-l1, so 2 would expand all of them; 1 opens only + # the current module's branch. Render depth is unaffected (navigation_depth). + "show_nav_level": 1, "navigation_depth": 4, - "secondary_sidebar_items": {"**": ["page-toc"], "index": []}, + "secondary_sidebar_items": {"**": ["page-toc"], "index": ["page-toc", "resources"]}, "back_to_top_button": True, "show_version_warning_banner": False, "icon_links": [{"name": "GitHub", @@ -200,4 +203,5 @@ if not _in_source_tree(SPHINX_INPUT_ROOT): def setup(app): app.connect("source-read", _source_read) app.connect("build-finished", _inline_coll_graphs_on_finish) + conf_helpers.patches.register_global_sidebar(app) return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/docs_sphinx/conf_helpers/build.py b/docs_sphinx/conf_helpers/build.py index 2bad665950..d228bf1c53 100644 --- a/docs_sphinx/conf_helpers/build.py +++ b/docs_sphinx/conf_helpers/build.py @@ -109,6 +109,98 @@ for _m in CONTRIB_MODULES: _IMAGE_INDEX.setdefault(_img.name, f"contrib_modules/{_rel}") +# Tutorials can reference images that live only under samples/ (+ apps/), which +# Doxygen's IMAGE_PATH covered but the tutorial-only index misses (renders +# broken). Index+stage ONLY sample images a tutorial actually references and +# that a tutorial `images/` dir doesn't already provide, to avoid copying the +# whole samples image set. Staged under sample_pics/ like api_pics above. +_referenced_images: set[str] = set() +for _md in (DOC_ROOT / "tutorials").rglob("*.markdown"): + try: + _txt = _md.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + for _m in re.finditer(r'!\[[^\]]*\]\((?:[^)\s]*?/)?images/([^)\s]+)\)', _txt): + _referenced_images.add(pathlib.Path(_m.group(1)).name) +_missing_images = {n for n in _referenced_images if n not in _IMAGE_INDEX} +if _missing_images: + _sample_pics = SPHINX_INPUT_ROOT / "sample_pics" + _stage_samples = SPHINX_INPUT_ROOT != DOC_ROOT + for _base in (OPENCV_ROOT / "samples", OPENCV_ROOT / "apps"): + if not _base.is_dir() or not _missing_images: + continue + for _img in _base.rglob("*"): + if (_img.name in _missing_images and _img.is_file() + and _img.suffix.lower() in _IMAGE_EXTS): + _IMAGE_INDEX[_img.name] = f"sample_pics/{_img.name}" + _missing_images.discard(_img.name) # first match wins; stop looking + if _stage_samples: + _sample_pics.mkdir(parents=True, exist_ok=True) + _link = _sample_pics / _img.name + if not _link.exists(): + try: + _os.symlink(_img, _link) + except (OSError, NotImplementedError): + try: + _shutil.copy2(_img, _link) + except OSError: + pass + if not _missing_images: + break + + +# Hand-authored dnn topic; content lives in the dnn module's own doc dir. +_DNN_ENGINE_SELECTION_MD = ( + OPENCV_ROOT / "modules" / "dnn" / "doc" / "dnn_engine.markdown").read_text( + encoding="utf-8") + + +def _add_dnn_engine_selection_topic(out_dir: pathlib.Path) -> None: + """Add the 'DNN Engine Selection' page as a dnn-module topic. + Must run after stub generation (its stale-file sweep) and before the anchor + scan, so the new {#anchor} + @subpage get picked up.""" + dnn = out_dir / "dnn.md" + if not dnn.is_file(): + return + (out_dir / "dnn_engine_selection.md").write_text( + _DNN_ENGINE_SELECTION_MD, encoding="utf-8") + text = dnn.read_text(encoding="utf-8") + if "api_dnn_engine_selection" in text: + return + new = re.sub( + r"(## Topics\n\n(?:- @subpage [^\n]*\n)+)", + lambda m: m.group(1) + "- @subpage api_dnn_engine_selection\n", + text, count=1) + if new != text: + dnn.write_text(new, encoding="utf-8") + + +# Hand-authored standalone HAL page; content lives in core's own doc dir. +_HAL_MD = ( + OPENCV_ROOT / "modules" / "core" / "doc" / "hal.markdown").read_text( + encoding="utf-8") + + +def _add_hal_page(out_dir: pathlib.Path) -> None: + """Write the HAL page and add a 'Learn about HAL' link on the api_root page. + Call after stub generation and before the anchor scan.""" + api_root = out_dir / "api_root.markdown" + if not api_root.is_file(): + return + (out_dir / "hal.md").write_text(_HAL_MD, encoding="utf-8") + text = api_root.read_text(encoding="utf-8") + if "](hal.md)" in text: + return + text = re.sub(r"(```\{toctree\}\n.*?\n)(```\n)", r"\1hal\n\2", + text, count=1, flags=re.S) + text = text.rstrip() + ( + "\n\n## Learn about HAL\n\n" + "OpenCV ships a Hardware Acceleration Layer that lets hardware vendors " + "inject tuned, silicon-specific implementations behind a stable C " + "interface. See [OpenCV Hardware Acceleration Layer (HAL)](hal.md).\n") + api_root.write_text(text, encoding="utf-8") + + if API_MODULES: _api_pics = SPHINX_INPUT_ROOT / "api_pics" _stage_pics = SPHINX_INPUT_ROOT != DOC_ROOT @@ -146,6 +238,8 @@ if API_MODULES: _generate_api_stubs(_main_api, _API_XML_DIR, SPHINX_INPUT_ROOT / "main_modules", root_anchor="api_root", root_title="Main modules", extra_groups=_main_orphans) + _add_dnn_engine_selection_topic(SPHINX_INPUT_ROOT / "main_modules") + _add_hal_page(SPHINX_INPUT_ROOT / "main_modules") _scan_internal(SPHINX_INPUT_ROOT / "main_modules") if _extra_api or _extra_orphans: _generate_api_stubs(_extra_api, _API_XML_DIR, SPHINX_INPUT_ROOT / "extra_modules", @@ -176,6 +270,11 @@ def _write_root_index() -> None: entries.append((heading, link_text, docname)) add("Introduction", "Introduction", "intro", "intro" in _ANCHOR_TO_DOC) + # Its own landing section rather than a bullet in the intro's Usage list. + _prebuilt = _ANCHOR_TO_DOC.get("tutorial_using_prebuilt_binaries") + add("How to use pre-built OpenCV binaries", + "Using OpenCV pre-built binaries in your own projects", + _prebuilt or "", bool(_prebuilt)) add("OpenCV Tutorials", "OpenCV tutorials", "tutorials/tutorials") add("Python Tutorials", "OpenCV-Python tutorials", "py_tutorials/py_tutorials", bool(PY_DOC_MODULES)) @@ -194,27 +293,33 @@ def _write_root_index() -> None: toctree = "\n".join( f"{heading} <{docname}>" for heading, _link, docname in entries) - # Body: raw HTML so links resolve correctly relative to index.html. - html_lines = ['
'] + # Markdown H2 headings so each shows in the "On this page" TOC; links stay + # raw HTML to resolve relative to index.html (headings can't sit in a
). + body_lines: list[str] = [] for heading, link_text, docname in entries: - if link_text is None: - html_lines.append( - f'

{heading}

') - else: - html_lines.append(f'

{heading}

') - html_lines.append(f'

{link_text}

') - html_lines.append("
") - body = "\n".join(html_lines) + body_lines.append(f"## {heading}\n") + body_lines.append( + f'\n') + body = "\n".join(body_lines).rstrip() + + # Landing-page intro prose lives in a hand-editable Markdown file next to + # conf.py (docs_sphinx/index_intro.md), not inline here. + _intro_md = pathlib.Path(__file__).resolve().parent.parent / "index_intro.md" + try: + intro = _intro_md.read_text(encoding="utf-8").strip() + except OSError: + intro = "" text = ( - "OpenCV modules\n" - "==============\n\n" + "OpenCV documentation\n" + "====================\n\n" "```{toctree}\n" ":hidden:\n" ":maxdepth: 1\n" ":titlesonly:\n\n" f"{toctree}\n" "```\n\n" + f"{intro}\n\n" f"{body}\n" ) try: diff --git a/docs_sphinx/conf_helpers/patches.py b/docs_sphinx/conf_helpers/patches.py index 3bc4ae303a..cdb109baa2 100644 --- a/docs_sphinx/conf_helpers/patches.py +++ b/docs_sphinx/conf_helpers/patches.py @@ -7,6 +7,8 @@ """Runtime patches for Sphinx C++ domain and breathe; applied at import.""" from __future__ import annotations +import re + def _patch_cpp_xref_resolver(): """Work around Sphinx 8.1.x parentSymbol assert in _resolve_xref_inner.""" try: @@ -169,3 +171,123 @@ def _silence_orphan_toctree_warning(): _silence_orphan_toctree_warning() + + +def _patch_sidebar_section_root(): + """Root the left sidebar at a page's own top-level section. + + A page in two toctrees (e.g. a `cuda*` extra module also grouped under main + `cuda`) gets a last-wins parent from `_get_toctree_ancestors`, rooting its + sidebar at the foreign section. Re-pick the parent sharing the longest path + prefix (same section) so the full sibling list shows.""" + try: + import pydata_sphinx_theme.toctree as _pt + from sphinx.environment.adapters.toctree import TocTree + except ImportError: + return + + def _section_aware_ancestor(app, pagename, startdepth): + ti = app.env.toctree_includes + cand: dict[str, list[str]] = {} + for _p, _children in ti.items(): + for _c in _children: + cand.setdefault(_c, []).append(_p) + + def _shared(parent: str, child: str) -> int: + a, b, i = parent.split("/"), child.split("/"), 0 + while i < len(a) and i < len(b) and a[i] == b[i]: + i += 1 + return i + + ancestors: list[str] = [] + d = pagename + while d not in ancestors: + ps = cand.get(d) + if not ps: + break + ancestors.append(d) + d = max(ps, key=lambda p: _shared(p, d)) + try: + out = ancestors[-startdepth] + except IndexError: + out = None + # Childless root => empty sidebar. Fall back to the dead-end ancestor `d` + # when it's a same-section page with children, else the section api_root. + if out is None or not ti.get(out): + _sec = pagename.split("/", 1)[0] + _base = pagename.rsplit("/", 1)[-1] + # Doxygen file/dir-reference pages are orphan utilities, not module + # content: leave None to suppress the sidebar rather than root at api_root. + if re.search(r"_8\w+$", _base) or _base.startswith("dir_"): + out = None + elif d != pagename and ti.get(d) and d.split("/", 1)[0] == _sec: + out = d + elif ti.get(_sec + "/api_root"): + out = _sec + "/api_root" + return out, TocTree(app.env) + + _pt._get_ancestor_pagename = _section_aware_ancestor + + +_patch_sidebar_section_root() + + +def _patch_sphinx_toctree_ancestors(): + """Fix which branch the collapsed startdepth=0 sidebar auto-expands. + + Sphinx picks it via `_get_toctree_ancestors`, whose last-wins parent map + mis-picks for a page in two toctrees (e.g. a `cuda*` extra module also under + main `cuda`), so its section won't expand. Prefer the parent sharing the + longest path prefix (same section).""" + try: + import sphinx.environment.adapters.toctree as _st + except ImportError: + return + + def _section_aware(toctree_includes, docname): + cand: dict[str, list[str]] = {} + for _p, _children in toctree_includes.items(): + for _c in _children: + cand.setdefault(_c, []).append(_p) + + def _shared(parent: str, child: str) -> int: + a, b, i = parent.split("/"), child.split("/"), 0 + while i < len(a) and i < len(b) and a[i] == b[i]: + i += 1 + return i + + ancestors: list[str] = [] + d = docname + while d not in ancestors: + ps = cand.get(d) + if not ps: + break + ancestors.append(d) + d = max(ps, key=lambda p: _shared(p, d)) + return dict.fromkeys(ancestors).keys() + + _st._get_toctree_ancestors = _section_aware + + +_patch_sphinx_toctree_ancestors() + + +def register_global_sidebar(app): + """Make the sidebar list ALL top-level sections (startdepth=0), current one + auto-expanded, instead of only the active section's subtree. + + Wrap the theme's `generate_toctree_html` (its sole sidebar nav generator) to + force startdepth=0, connecting after the theme's html-page-context handler so + it keeps its own template and we flip only this arg. This makes + `_patch_sidebar_section_root` a no-op (its lookup only runs when startdepth != 0).""" + def _globalize(app, pagename, templatename, context, doctree): + gen = context.get("generate_toctree_html") + if not callable(gen): + return + def wrapped(kind, startdepth=0, show_nav_level=0, **kwargs): + # collapse=True: expand only the current branch. Without it, + # startdepth=0 renders the whole tree on every page (slow + bloated). + kwargs["collapse"] = True + return gen(kind, startdepth=0, show_nav_level=0, **kwargs) + context["generate_toctree_html"] = wrapped + app.connect("html-page-context", _globalize, priority=900) diff --git a/docs_sphinx/conf_helpers/postprocess.py b/docs_sphinx/conf_helpers/postprocess.py index 53fbae4e0b..43e1d9e911 100644 --- a/docs_sphinx/conf_helpers/postprocess.py +++ b/docs_sphinx/conf_helpers/postprocess.py @@ -9,7 +9,8 @@ from __future__ import annotations import pathlib, re from .state import (_doxy_page_to_local, _DOXY_ANCHOR_TO_MEMBER, DOXYGEN_BASE_URL, - _LOCAL_CLASS_URL, _LOCAL_TYPEDEF_URL, _FILE_URL, _API_XML_DIR, DOC_ROOT) + _LOCAL_CLASS_URL, _LOCAL_TYPEDEF_URL, _FILE_URL, _API_XML_DIR, DOC_ROOT, + _CV_SYMBOL_URL) def _doxy_parent_page(page: str, api_dir: pathlib.Path) -> str: @@ -171,12 +172,14 @@ def _copy_js_tryit_files(out_dir: pathlib.Path) -> None: dst = dest / src.name if not dst.exists(): shutil.copy2(src, dst) - # opencv.js from CMake (OPENCV_JS_PATH); bundle it alongside the Try-it pages. + # opencv.js from CMake (OPENCV_JS_PATH). Needed both alongside the Try-it + # pages (relative `src="opencv.js"`) AND at the doc-site root, where + # external/js_usage docs link https://docs.opencv.org//opencv.js. opencv_js = os.environ.get("OPENCV_JS_PATH", "") if opencv_js and pathlib.Path(opencv_js).is_file(): - dst = dest / "opencv.js" - if not dst.exists(): - shutil.copy2(opencv_js, dst) + for dst in (dest / "opencv.js", dest.parent / "opencv.js"): + if not dst.exists(): + shutil.copy2(opencv_js, dst) # Extra assets referenced by Try-it pages but not in js_assets/. _opencv_root = DOC_ROOT.parent for _name, _src in { @@ -328,6 +331,28 @@ _PYG_CPF_SPAN_RE = re.compile( ) +# C++ free-function linkifier. The `(` lookahead means only call sites match, +# never a like-named local (`log`, `min`, …); it also makes the pass idempotent +# and class-safe (a wrapped/constructor name is followed by ``, not `(`). +_PYG_CPP_PRE_RE = re.compile( + r'(?P
)'
+    r'(?P.*?)(?P
)', re.DOTALL) +_PYG_CALL_SPAN_RE = re.compile( + r'(?P)(?P[A-Za-z_][A-Za-z0-9_]*)(?P)' + r'(?=\()' +) +# Python free-function calls. Requires a `cv.`/`cv2.` prefix (OpenCV's Python +# API is always module-qualified) so bare builtins / other modules never link. +_PYG_PY_PRE_RE = re.compile( + r'(?P
' + r'
)(?P.*?)(?P
)', re.DOTALL) +_PYG_PY_CALL_RE = re.compile( + r'(?P
cv2?\.)'
+    r'(?P)(?P[A-Za-z_][A-Za-z0-9_]*)(?P)'
+    r'(?=\()'
+)
+
+
 def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
     """Walk every `.html` under `html_dir` and turn known identifier
     tokens inside Pygments-rendered `
` blocks into clickable
@@ -335,15 +360,73 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None:
     don't accidentally repaint inline `` chips in
     prose; the rule above already targets only Pygments span classes
     that Pygments uses inside its `
` output."""
-    if not (_LOCAL_CLASS_URL or _LOCAL_TYPEDEF_URL or _FILE_URL):
+    if not (_LOCAL_CLASS_URL or _LOCAL_TYPEDEF_URL or _FILE_URL or _CV_SYMBOL_URL):
         return
     if not html_dir.is_dir():
         return
     import os
 
+    # basename -> path relative to the html root. Stored URLs are relative to
+    # main_modules/, so without re-pointing they 404 when linked from a page at
+    # a different depth (e.g. a tutorial referencing `core_basic.html#…`).
+    _skip_dirs = {"_static", "_sources", "_images", "_sphinx_design_static"}
+    _page_paths: dict[str, str] = {}
+    for _f in html_dir.rglob("*.html"):
+        _rel = _f.relative_to(html_dir)
+        if _rel.parts and _rel.parts[0] in _skip_dirs:
+            continue
+        _page_paths.setdefault(_f.name, _rel.as_posix())
+
+    def _rel_local(url: str, current_html: pathlib.Path) -> "str | None":
+        # Relativize a bare local Sphinx page URL to the current page. Returns
+        # None when the target page wasn't built (e.g. a struct documented
+        # inline, with no standalone page) so the caller drops the link instead
+        # of emitting a 404. Non-local URLs (http/already-relative) pass through.
+        page, sep, frag = url.partition("#")
+        if page.startswith(("http://", "https://", "../", "/")):
+            return url
+        tgt = _page_paths.get(page)
+        if not tgt:
+            return None
+        rel = os.path.relpath(html_dir / tgt, start=current_html.parent)
+        return rel + (sep + frag if sep else "")
+
     def _resolve(name: str) -> str | None:
         return _LOCAL_CLASS_URL.get(name) or _LOCAL_TYPEDEF_URL.get(name)
 
+    # Free functions only (classes/typedefs already get a local link via
+    # `_resolve`). Emits the docs.opencv.org URL; `_localize_doxygen_links`
+    # (run right after) rewrites it to the local Sphinx page when one exists.
+    def _resolve_fn(name: str) -> str | None:
+        if name in _LOCAL_CLASS_URL or name in _LOCAL_TYPEDEF_URL:
+            return None
+        return _CV_SYMBOL_URL.get(name)
+
+    def _wrap_call(m: "re.Match") -> str:
+        url = _resolve_fn(m.group("name"))
+        if not url:
+            return m.group(0)
+        return (f''
+                f'{m.group("span")}{m.group("name")}{m.group("end")}')
+
+    def _rewrite_cpp_calls(m: "re.Match") -> str:
+        return (m.group("open")
+                + _PYG_CALL_SPAN_RE.sub(_wrap_call, m.group("body"))
+                + m.group("close"))
+
+    def _wrap_py_call(m: "re.Match") -> str:
+        url = _resolve_fn(m.group("name"))
+        if not url:
+            return m.group(0)
+        return (m.group("pre")
+                + f''
+                + f'{m.group("span")}{m.group("name")}{m.group("end")}')
+
+    def _rewrite_py_calls(m: "re.Match") -> str:
+        return (m.group("open")
+                + _PYG_PY_CALL_RE.sub(_wrap_py_call, m.group("body"))
+                + m.group("close"))
+
     # `
` blocks only — keeps the substitution from touching # inline `` runs that may appear in other contexts. _PRE_BLOCK_RE = re.compile(r"
(.*?)
", re.DOTALL) @@ -361,11 +444,14 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None: except ValueError: return f"../../../doc/doxygen/html/{file_url}" - def _wrap_span(m: re.Match) -> str: + def _wrap_span(m: re.Match, current_html: pathlib.Path) -> str: name = m.group("name") url = _resolve(name) if not url: return m.group(0) + url = _rel_local(url, current_html) + if url is None: # target page not built — don't emit a 404 link + return m.group(0) return (f'' f'{m.group("prefix")}{name}{m.group("suffix")}') @@ -408,13 +494,13 @@ def _linkify_code_blocks(html_dir: pathlib.Path) -> None: k = inner.find(" None: continue new_text = _PRE_BLOCK_RE.sub( lambda m: _rewrite_pre(m, html), text) + # Function-call passes run after the class/typedef pass so constructors + # are already wrapped and skipped by the `(` lookahead. + new_text = _PYG_CPP_PRE_RE.sub(_rewrite_cpp_calls, new_text) + new_text = _PYG_PY_PRE_RE.sub(_rewrite_py_calls, new_text) if new_text != text: try: html.write_text(new_text, encoding="utf-8") @@ -501,6 +591,240 @@ def _linkify_inline_code(html_dir: pathlib.Path) -> None: pass +# Center the active nav entry in the scrollable left sidebar on load so a +# module far down the list isn't below the fold. The theme has no such hook. +_AUTOSCROLL_SNIPPET = ( + '" +) + + +def _inject_sidebar_autoscroll(out_dir: pathlib.Path) -> None: + """Inject the sidebar auto-scroll script before ; idempotent.""" + for html in out_dir.rglob("*.html"): + text = html.read_text(encoding="utf-8") + if "opencv-sidebar-autoscroll" in text or "" not in text: + continue + html.write_text( + text.replace("", _AUTOSCROLL_SNIPPET + "", 1), + encoding="utf-8") + + +_TOC_NAV_RE = re.compile(r'id="pst-page-toc-nav".*?', re.S) +_TOC_HREF_RE = re.compile(r'href="#([^"]+)"') +_ANY_ID_RE = re.compile(r'id="([^"]+)"') +_DETAIL_SEC_RE = re.compile(r'(
]*>)') + + +def _repair_dangling_toc_anchors(out_dir: pathlib.Path) -> None: + """Stub a missing #anchor for any secondary-TOC link with no target element. + A dangling anchor makes the theme scroll-spy throw, which aborts init and + kills the collapse-sidebar button on that page. Idempotent.""" + for html in out_dir.rglob("*.html"): + text = html.read_text(encoding="utf-8") + nav = _TOC_NAV_RE.search(text) + if not nav: + continue + ids = set(_ANY_ID_RE.findall(text)) + missing = [a for a in _TOC_HREF_RE.findall(nav.group(0)) if a not in ids] + if not missing: + continue + stubs = "".join(f'' for a in missing) + new = _DETAIL_SEC_RE.sub(lambda m: m.group(1) + stubs, text, count=1) + if new != text: + html.write_text(new, encoding="utf-8") + + +def _redirect_orphan_duplicates(app, out_dir: pathlib.Path) -> None: + """An orphaned main_modules/* page that duplicates an in-nav extra_modules + page redirects to that twin, which has proper section nav. Idempotent.""" + ti = app.env.toctree_includes + seen, stack = set(), [app.env.config.root_doc] + while stack: + d = stack.pop() + if d in seen: + continue + seen.add(d) + stack.extend(ti.get(d, [])) + for doc in set(app.env.all_docs): + if not doc.startswith("main_modules/") or doc in seen: + continue + base = doc.split("/", 1)[1] + if "extra_modules/" + base not in seen: + continue + html = out_dir / (doc + ".html") + if not html.is_file(): + continue + text = html.read_text(encoding="utf-8") + if "opencv-dup-redirect" in text: + continue + target = f"../extra_modules/{base}.html" + snippet = ( + f'' + f'' + f'' + ) + new = text.replace("", "" + snippet, 1) + if new != text: + html.write_text(new, encoding="utf-8") + + +# DNN engine-selection topic: symbols → their API anchor (same dir as the page). +_DNN_ENGINE_LINKS = { + "readNet": "dnn.html#readnet", + "readNetFromONNX": "dnn.html#readnetfromonnx", + "ENGINE_NEW": "dnn.html#enginetype", + "ENGINE_CLASSIC": "dnn.html#enginetype", + "ENGINE_AUTO": "dnn.html#enginetype", + "ENGINE_ORT": "dnn.html#enginetype", + "EngineType": "dnn.html#enginetype", + "DNN_BACKEND_CUDA": "dnn.html#backend", + "DNN_BACKEND_OPENVINO": "dnn.html#backend", + "DNN_TARGET_CUDA": "dnn.html#target", + "setPreferableBackend": "classcv_1_1dnn_1_1Net.html#setpreferablebackend", + "setPreferableTarget": "classcv_1_1dnn_1_1Net.html#setpreferabletarget", +} +# Whole inline-code spans (qualified forms) → anchor; bare names reuse the above. +_DNN_ENGINE_INLINE = { + "cv::dnn::readNet()": "dnn.html#readnet", + "net.forward()": "classcv_1_1dnn_1_1Net.html#forward", + "readNet*()": "dnn.html#readnet", + **_DNN_ENGINE_LINKS, +} +_INLINE_CODE_SPAN_RE = re.compile( + r'' + r'(?:)?([^<]+)(?:)?') +_PYG_TOKEN_SPAN_RE = re.compile(r'(\w+)') + + +def _linkify_dnn_engine_selection(out_dir: pathlib.Path) -> None: + """Make DNN engine/backend symbols clickable on the engine-selection topic, + in both inline code and highlighted code blocks. Idempotent.""" + page = out_dir / "main_modules" / "dnn_engine_selection.html" + if not page.is_file(): + return + text = page.read_text(encoding="utf-8") + if 'href="dnn.html#enginetype"' in text: # already linkified + return + + def _inline(m: "re.Match") -> str: + href = _DNN_ENGINE_INLINE.get(m.group(1).strip()) + return (f'' + f'' + f'{m.group(1)}' + ) if href else m.group(0) + + def _token(m: "re.Match") -> str: + href = _DNN_ENGINE_LINKS.get(m.group(2)) + return (f'' + f'{m.group(2)}' + ) if href else m.group(0) + + text = _PYG_TOKEN_SPAN_RE.sub(_token, _INLINE_CODE_SPAN_RE.sub(_inline, text)) + page.write_text(text, encoding="utf-8") + + +# HAL page: cv::* API symbols -> their module-page anchors (same dir as the page). +_HAL_INLINE = { + "cv::resize": "imgproc_transform.html#resize", + "cv::cvtColor": "imgproc_color_conversions.html#cvtcolor", + "cv::gemm": "core_array.html#gemm", +} + + +def _linkify_hal_page(out_dir: pathlib.Path) -> None: + """Make the cv::* API symbols on the HAL page clickable. Idempotent. + HAL-only tokens (cv_hal_*, NOT_IMPLEMENTED, WITH_*, …) have no API page and + are intentionally left plain.""" + page = out_dir / "main_modules" / "hal.html" + if not page.is_file(): + return + text = page.read_text(encoding="utf-8") + if 'href="imgproc_transform.html#resize"' in text: # already linkified + return + + def _inline(m: "re.Match") -> str: + href = _HAL_INLINE.get(m.group(1).strip()) + return (f'' + f'' + f'{m.group(1)}' + ) if href else m.group(0) + + page.write_text(_INLINE_CODE_SPAN_RE.sub(_inline, text), encoding="utf-8") + + +# Universal Intrinsics tutorial: link the intrinsics/types in prose code, which +# Sphinx doesn't auto-link. Anchors are read off core_hal_intrin + the symbol +# maps so coverage tracks the docs (undocumented symbols stay plain). +_UI_MM = "../../../main_modules/" # page is 3 dirs deep + + +def _univ_intrin_link_map(out_dir: pathlib.Path) -> dict: + m = {"cv::hfloat": _UI_MM + "classcv_1_1hfloat.html", + "cv::bfloat": _UI_MM + "classcv_1_1bfloat.html", + "CV_16F": _UI_MM + "core_hal_interface.html#cv-16f", + "CV_16BF": _UI_MM + "core_hal_interface.html#cv-16bf"} + # Intrinsic functions, from the anchors on the rendered group page. + hp = out_dir / "main_modules" / "core_hal_intrin.html" + if hp.is_file(): + html = hp.read_text(encoding="utf-8") + for a in set(re.findall(r'id="(cv-v-[a-z0-9-]+)"', html)): + m.setdefault("v_" + a[len("cv-v-"):].replace("-", "_"), + _UI_MM + "core_hal_intrin.html#" + a) + # Register typedefs (v_float32, …) and the FP16/BF16 classes, from the maps. + for sym, url in {**_LOCAL_TYPEDEF_URL, **_LOCAL_CLASS_URL}.items(): + if sym.startswith("v_") or sym in ("hfloat", "bfloat"): + m.setdefault(sym, _UI_MM + url) + # Symbols with no local page but in the Doxygen tag (v_exp, v_log, …) link + # to the official docs; those absent here too have no target and stay plain. + for sym, url in _CV_SYMBOL_URL.items(): + if sym.startswith(("v_", "vx_")) and sym not in m: + m[sym] = url + return m + + +def _linkify_univ_intrin(out_dir: pathlib.Path) -> None: + """Link every documented intrinsic/type on the univ_intrin tutorial (inline + code and highlighted blocks). Idempotent; undocumented symbols stay plain.""" + page = out_dir / "tutorials" / "core" / "univ_intrin" / "univ_intrin.html" + if not page.is_file(): + return + text = page.read_text(encoding="utf-8") + if _UI_MM + "classcv_1_1hfloat.html" in text: # already linkified + return + links = _univ_intrin_link_map(out_dir) + + def _inline(m: "re.Match") -> str: + txt = m.group(1).strip() + href = links.get(txt) + if not href: # e.g. "v_exp(x)" -> v_exp + lead = re.match(r"[A-Za-z_:][\w:]*", txt) + href = links.get(lead.group(0)) if lead else None + return (f'' + f'' + f'{m.group(1)}' + ) if href else m.group(0) + + def _token(m: "re.Match") -> str: + href = links.get(m.group(2)) + return (f'' + f'{m.group(2)}' + ) if href else m.group(0) + + text = _INLINE_CODE_SPAN_RE.sub(_inline, text) + # Token-link only outside existing spans (typedefs/classes are + # already linked by _linkify_code_blocks) so we never nest anchors. + parts = re.split(r"(]*>.*?)", text, flags=re.DOTALL) + text = "".join(p if i % 2 else _PYG_TOKEN_SPAN_RE.sub(_token, p) + for i, p in enumerate(parts)) + page.write_text(text, encoding="utf-8") + + def _inline_coll_graphs_on_finish(app, exception): """build-finished entry point.""" if exception is not None: @@ -516,3 +840,9 @@ def _inline_coll_graphs_on_finish(app, exception): _copy_js_tryit_files(out) _fix_gapi_images(out) _generate_search_map(out) + _repair_dangling_toc_anchors(out) + _redirect_orphan_duplicates(app, out) + _linkify_dnn_engine_selection(out) + _linkify_hal_page(out) + _linkify_univ_intrin(out) + _inject_sidebar_autoscroll(out) diff --git a/docs_sphinx/conf_helpers/state.py b/docs_sphinx/conf_helpers/state.py index 361aac27d3..99e0a1fc44 100644 --- a/docs_sphinx/conf_helpers/state.py +++ b/docs_sphinx/conf_helpers/state.py @@ -137,6 +137,64 @@ def _module_group_stem(m: str) -> str: return _mm.group(1) if _mm else m return m +# -- Header-free API-group doc overrides ------------------------------------ +# When a module's headers use `@addtogroup` only (never `@defgroup`), Doxygen +# emits its subgroups un-nested and auto-titled from their id, leaving the module +# landing page empty. These overrides supply the module-group description and +# re-parent + retitle the subgroups at stub time, without editing the headers +# (cf. `_XPHOTO_DOCS` in stubs.py). Keyed by module group stem (== group id == +# module folder, by convention). +_GROUP_DOC_OVERRIDES: dict = { + "geometry": { + "detailed": ( + "Coordinate geometry grouped into one module: 2D shape analysis and " + "fitting, planar subdivision, multi-view 3D vision (camera pose, " + "triangulation, homography) and point-cloud sampling and " + "segmentation. In OpenCV 5 these were consolidated here from the " + "[imgproc](imgproc.md) and [calib](calib.md) modules.\n\n" + "**Migration (OpenCV 5):** these symbols are no longer re-exported " + "by [imgproc](imgproc.md); code that calls e.g. " + "[convexHull](geometry_shape.md) must include " + "[opencv2/geometry.hpp](geometry_8hpp.md) directly." + ), + # Direct children to nest under the module page (each pulls in its own + # subgroups recursively). NB: "d_projection" is Doxygen's mangling of + # `@defgroup 3d_projection` (a group id can't start with a digit), and + # "_3d" is never `@defgroup`'d. + "subgroups": ["geometry_shape", "d_projection", "_3d"], + }, + "ptcloud": { + "detailed": ( + "Point-cloud and mesh processing: reading and writing point clouds " + "and meshes, triangle rasterization, spatial partitioning (octree), " + "and RGB-D / volumetric 3D reconstruction (odometry, TSDF volumes)." + ), + # Symbols harvested from the cv namespace (see _GROUP_NS_HARVEST). + }, +} +# Title fixes for groups Doxygen auto-titled from their id, applied by group id +# to every node in an overridden module's tree. Groups with a real title are +# left out (e.g. "d_projection" already renders as "3D vision functionality"). +_GROUP_TITLE_OVERRIDES: dict = { + "geometry": "Computational Geometry Primitives Module", + "geometry_shape": "Shape analysis and fitting", + "geometry_subdiv2d": "Planar subdivision", + "_3d": "Point-cloud sampling and segmentation", + "ptcloud": "Point Cloud Processing", # header typo'd "Clound" +} +# Group ids re-parented by an override — skipped by orphan-group emission so each +# renders once, nested under its module, not also as a standalone page. +_GROUP_OVERRIDE_SUBGROUPS: set = { + _sub for _ov in _GROUP_DOC_OVERRIDES.values() + for _sub in _ov.get("subgroups", ()) +} +# group stem -> include prefix: harvest cv-namespace symbols (orphaned when a +# header opens @addtogroup outside `namespace cv`) back into the module page. +_GROUP_NS_HARVEST: dict = { + "ptcloud": "opencv2/ptcloud", + "geometry": "opencv2/geometry/mst.hpp", # mst.hpp has no @addtogroup +} + # -- Python enum/constant signatures ---------------------------------------- # C++ enumerator FQN -> cv2.* name; env OPENCV_PYTHON_SIGNATURES_FILE _PY_SIGNATURES: dict = {} @@ -686,7 +744,7 @@ def _bib_fields(body: str) -> dict[str, str]: return fields # LaTeX accent + special-char cleanup -_LATEX_ACCENT_RE = re.compile(r"\\([\"'`^~.])\s*\{?\s*([A-Za-z])\s*\}?") +_LATEX_ACCENT_RE = re.compile(r"\\([\"'`^~.])\s*\{?\s*\\?([A-Za-z])\s*\}?") _LATEX_ACCENT_MAP = { ('"', 'a'): 'ä', ('"', 'e'): 'ë', ('"', 'i'): 'ï', ('"', 'o'): 'ö', ('"', 'u'): 'ü', ('"', 'A'): 'Ä', ('"', 'O'): 'Ö', ('"', 'U'): 'Ü', @@ -700,10 +758,26 @@ _LATEX_ACCENT_MAP = { ('~', 'a'): 'ã', ('~', 'n'): 'ñ', ('~', 'o'): 'õ', ('.', 'c'): 'ċ', ('.', 'e'): 'ė', } +# Letter-command accents: \c c (cedilla), \v s (caron), \u g (breve), \H o +# (double acute), \k a (ogonek), \r u (ring). Form is "\c{c}" or "\c c". +_LATEX_CMD_ACCENT_RE = re.compile(r"\\([cvuHkr])(?:\s+|\{)\s*\\?([A-Za-z])\s*\}?") +_LATEX_CMD_ACCENT_MAP = { + ('c', 'c'): 'ç', ('c', 'C'): 'Ç', ('c', 's'): 'ş', ('c', 'S'): 'Ş', + ('c', 'g'): 'ģ', ('c', 'e'): 'ȩ', + ('v', 'c'): 'č', ('v', 'C'): 'Č', ('v', 's'): 'š', ('v', 'S'): 'Š', + ('v', 'z'): 'ž', ('v', 'Z'): 'Ž', ('v', 'r'): 'ř', ('v', 'n'): 'ň', + ('v', 'e'): 'ě', ('v', 'd'): 'ď', ('v', 't'): 'ť', ('v', 'g'): 'ǧ', + ('u', 'g'): 'ğ', ('u', 'a'): 'ă', + ('H', 'o'): 'ő', ('H', 'u'): 'ű', ('H', 'O'): 'Ő', ('H', 'U'): 'Ű', + ('k', 'a'): 'ą', ('k', 'e'): 'ę', ('k', 'A'): 'Ą', ('k', 'E'): 'Ę', + ('r', 'u'): 'ů', ('r', 'a'): 'å', ('r', 'U'): 'Ů', ('r', 'A'): 'Å', +} _LATEX_SPECIAL = { r"\&": "&", r"\%": "%", r"\#": "#", r"\$": "$", r"\_": "_", r"\{": "{", r"\}": "}", r"\textendash": "–", r"\textemdash": "—", + r"\textregistered": "®", r"\texttrademark": "™", + r"\textcopyright": "©", r"\copyright": "©", r"\ldots": "…", r"\dots": "…", r"\o": "ø", r"\O": "Ø", r"\ss": "ß", r"\aa": "å", r"\AA": "Å", r"\ae": "æ", r"\AE": "Æ", @@ -712,8 +786,13 @@ _LATEX_SPECIAL = { def _bib_clean(s: str) -> str: s = re.sub(r"\s+", " ", s or "").strip() + s = re.sub(r"\\url\s*\{([^}]*)\}", r"\1", s) # \url{X} -> X + s = re.sub(r"\\([lL])(?![A-Za-z])", # \l -> ł, \L -> Ł + lambda m: "ł" if m.group(1) == "l" else "Ł", s) s = _LATEX_ACCENT_RE.sub( lambda m: _LATEX_ACCENT_MAP.get((m.group(1), m.group(2)), m.group(2)), s) + s = _LATEX_CMD_ACCENT_RE.sub( + lambda m: _LATEX_CMD_ACCENT_MAP.get((m.group(1), m.group(2)), m.group(2)), s) for k, v in _LATEX_SPECIAL.items(): s = s.replace(k, v) return s.replace("{", "").replace("}", "").strip() @@ -991,6 +1070,8 @@ __all__ = [ "DOC_MODULES", "JS_DOC_MODULES", "PY_DOC_MODULES", "CONTRIB_MODULES", "CONTRIB_ROOT", "SPHINX_INPUT_ROOT", "API_MODULES", "_API_XML_DIR", "_PATCHED_XML_DIR", "_module_group_stem", + "_GROUP_DOC_OVERRIDES", "_GROUP_OVERRIDE_SUBGROUPS", "_GROUP_TITLE_OVERRIDES", + "_GROUP_NS_HARVEST", "_PY_SIGNATURES", "_python_enum_name", "HAVE_SPHINX_DESIGN", "HAVE_BREATHE", "DOXYGEN_BASE_URL", "_doxygen_url", diff --git a/docs_sphinx/conf_helpers/stubs.py b/docs_sphinx/conf_helpers/stubs.py index ce230cf731..9bcc6ab5d9 100644 --- a/docs_sphinx/conf_helpers/stubs.py +++ b/docs_sphinx/conf_helpers/stubs.py @@ -114,6 +114,12 @@ def _enhance_xphoto_member(m: dict, class_name: str = "") -> dict: # Drives write-if-changed and the stale-file sweep. _stub_written: set[pathlib.Path] = set() +# "Shell" groups (<=2 classes, no own members) merged with their classes; see +# _write_api_stub. _MERGED_GROUPS -> [(out_path, intro_lines, classes)]; +# _MERGED_CLASS_TO_GROUP -> {class_refid: group_docname} for cross-refs/redirect. +_MERGED_GROUPS: list = [] +_MERGED_CLASS_TO_GROUP: dict[str, str] = {} + def _stub_write(path: pathlib.Path, content: str) -> None: """Write only if changed; mark path live for this run.""" @@ -752,44 +758,14 @@ def _write_namespace_stub(ns: dict, out_dir: pathlib.Path, items = ns_sections.get(section_title, []) if not items: continue + # Drop the redundant summary when the detail loop covers every member + # (all but template specializations); keep Enumerations always. + if section_title != "Enumerations" and all( + "<" not in (m.get("name") or "") for m in items): + continue lines.append(f"## {section_title}") lines.append("") - if section_title == "Functions": - lines += ["{.api-reference-table .api-function-table}", - "| Return | Name |", "|---|---|"] - from html import escape as _esc_html_ns - def _ns_func_row(m: dict) -> str: - target = f"#{m['id']}" - qual = (m.get("qualified") or m["name"]) - name_text = qual.replace("::", "::") - name_html = (f'{name_text}') - params_sig = m.get("params_sig") or [] - def _esc(s: str) -> str: - return _esc_html_ns(s).replace("|", "|") - if not params_sig: - inner = f"{name_html}()" - elif len(params_sig) == 1: - t, nm, dv = params_sig[0] - decl = nm + (f" = {dv}" if dv else "") - inner = f"{name_html}({_esc(t)} {_esc(decl)})" - else: - last_i = len(params_sig) - 1 - parts = [f"{name_html}("] - for i, (t, nm, dv) in enumerate(params_sig): - tail = " )" if i == last_i else "," - decl = nm + (f" = {dv}" if dv else "") - parts.append(f" {_esc(t)} {_esc(decl)}{tail}") - inner = "
".join(parts) - return f'{inner}' - for m in items: - ret_md = _type_to_md(m.get("type_elem")) - if not ret_md: - ret_md = _md_escape_cell(m["type"]) or "\u00a0" - if m.get("static"): - ret_md = "static " + ret_md - lines.append(f"| {ret_md} | {_ns_func_row(m)} |") - elif section_title in ("Typedefs", "Variables"): + if section_title in ("Typedefs", "Variables"): for m in items: lines.append("```cpp") if section_title == "Typedefs": @@ -995,6 +971,23 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, _stub_write(out_dir / f"{_cn}.md", _md + "\n") return + # Classes-only shell group (<=2 classes, no own members/subgroups): defer the + # class content to the seeded pass (_generate_api_stubs), which hosts it here + # and redirects the standalone class pages. + if (1 <= len(node["innerclasses"]) <= 2 and not node["sections"] + and not node["children"]): + _intro = list(lines) + _txt = "\n\n".join(x for x in ( + (node.get("brief") or "").strip(), + (node.get("detailed") or "").strip()) if x) + if _txt: + _intro += [_txt, ""] + for c in node["innerclasses"]: + classes_seen.setdefault(c["refid"], c) + _MERGED_CLASS_TO_GROUP[c["refid"]] = f"{out_dir.name}/{name}" + _MERGED_GROUPS.append((out, _intro, node["innerclasses"])) + return + # Brief + "View details" under the title (mirrors the Doxygen group page). _brief = node.get("brief") or "" if _brief: @@ -1073,7 +1066,10 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, parent_qualified = q.rsplit("::", 1)[0] for c in classes_seen.values(): if c.get("qualified") == parent_qualified: - return f"{_class_page_name(c['refid'])}.md" + # Used in a raw-HTML (see _func_row_split_md); MyST + # only rewrites .md->.html for Markdown []() links, not raw + # HTML, so point at .html directly or the href 404s. + return f"{_class_page_name(c['refid'])}.html" # Functions on core pages: target the `_func_slug`-based anchor # that `_render_core_basic_func` actually emits. if _is_core_page and m.get("kind") == "function" and m.get("name"): @@ -1214,13 +1210,15 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, def _safe(s: str) -> str: return _html_mod.escape(s).replace("::", "::") for m in members: + # Named enums anchor on their `### Name` heading slug. Anonymous + # enums anchor on the detail block's MyST `({id})=` target, whose + # slug normalizes `_`-runs to `-`; a raw `#` with underscores + # does NOT resolve in MyST, so match the normalized form here. + _enum_anchor = (m["name"].lower() if m.get("name") + else re.sub(r"_+", "-", m["id"])) _more = "" if _enum_more_link: - # Link to the enum detail block's heading-slug id - # (`### AccessFlag` → `#accessflag`). Same target - # the clickable synopsis tokens use, and a literal - # match on the actual element id on the page. - _more = f"[View details](#{m['name'].lower()})" + _more = f"[View details](#{_enum_anchor})" if _clickable_synopsis: _qual = m["qualified"] or m["name"] _is_strong = bool(m.get("strong")) @@ -1234,7 +1232,7 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, _val_prefix = _qual.rsplit("::", 1)[0] + "::" else: _val_prefix = "" - _href = f"#{m['name'].lower()}" # enum detail block id + _href = f"#{_enum_anchor}" out.append( '
'
@@ -1288,7 +1286,15 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
         return out
 
     _named_groups: list[tuple[str, str, list]] = []   # (header, section_title, members)
-    for _, section_title in _MEMBERDEF_SECTIONS:
+    def _grp_detail_covers(m, kind_key):
+        # Must mirror the detail loop's skips below: a summary member lacks a
+        # detail block only if it's a class member or a template specialization.
+        if kind_key in ("function", "variable") and _is_class_member(m):
+            return False
+        if _is_template_spec(m):
+            return False
+        return True
+    for kind_key, section_title in _MEMBERDEF_SECTIONS:
         items = node["sections"].get(section_title, [])
         if not items:
             continue
@@ -1296,11 +1302,16 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
         for _hdr, _members in _group_by_section_header(
                 [m for m in items if (m.get("section_header") or "")]):
             _named_groups.append((_hdr, section_title, _members))
+        # Drop the redundant summary only when the detail loop covers every
+        # ungrouped member; keep Enumerations always.
         if ungrouped:
-            lines.append(f"## {section_title}")
-            lines.append("")
-            lines += _summary_block(section_title, ungrouped)
-            lines.append("")
+            _covered = section_title != "Enumerations" and all(
+                _grp_detail_covers(m, kind_key) for m in ungrouped)
+            if not _covered:
+                lines.append(f"## {section_title}")
+                lines.append("")
+                lines += _summary_block(section_title, ungrouped)
+                lines.append("")
     for _hdr, section_title, _members in _named_groups:
         lines.append(f"## {_hdr}")
         lines.append("")
@@ -1363,7 +1374,14 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path,
                         "",
                     ]
                 else:
-                    blk = [f'

{_keyword}

', ""] + # Anonymous enum has no heading slug; anchor it with a MyST + # `({id})=` target (what `_enum_anchor` links to). A raw + # `

` is not a MyST reference target and dead-links. + blk = [ + f"({m['id']})=", + f"### {_keyword}", + "", + ] if m.get("include_file"): _einc = m["include_file"] _ehref = _include_page_href(_einc) @@ -1768,17 +1786,19 @@ def _render_core_basic_func(m: dict, idx: int, total: int, def _write_class_stub(cls: dict, out_dir: pathlib.Path, - xml_dir: pathlib.Path) -> None: + xml_dir: pathlib.Path, inline: bool = False): """One .md per inner class, mirroring Doxygen's class-page layout. - Falls back to `{doxygenclass}`/`{doxygenstruct}` if class XML can't be read.""" + Falls back to `{doxygenclass}`/`{doxygenstruct}` if class XML can't be read. + When `inline=True`, the body lines are returned (no title, not written) so a + single-class "shell" group page can host the class content directly.""" page = _class_page_name(cls["refid"]) out = out_dir / f"{page}.md" qualified = cls["qualified"] or cls["name"] kind_label = cls["kind"].title() title = f"{kind_label} {qualified}" # No `{#refid}` anchor; `_generate_api_stubs` seeds `_ANCHOR_TO_DOC` instead. - lines = [f"# {title}", ""] + lines = [] if inline else [f"# {title}", ""] # Class-page header: brief + `View details` + `#include` line. _header_data = _read_class_data(cls["refid"], xml_dir) @@ -1786,10 +1806,12 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, import html as _html_pkg _brief = (_header_data.get("brief") or "").strip() if _brief: - # Link only when there's a detailed description to jump to. + # Link only when there's a detailed description to jump to; skip when + # inlined, where the #detailed-description anchor collides with a + # sibling class and the detail is right below anyway. _more = ( ' View details' - if _header_data.get("detailed") else "" + if _header_data.get("detailed") and not inline else "" ) lines.append( f'

' @@ -1852,16 +1874,20 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, inline_inherited = [] _additional += [(summary_title, rid, qual, bi) for rid, qual, bi in inherited] - if not items and not inline_inherited: + non_enum_items = [m for m in items if m["kind"] != "enum"] + enum_items = [m for m in items if m["kind"] == "enum"] + # Own typedef/function/variable get a detail block below, so drop their + # summary rows; keep kinds without one (e.g. friends) and enums. + kept_rows = [m for m in non_enum_items + if m["kind"] not in ("typedef", "function", "variable")] + if not kept_rows and not enum_items and not inline_inherited: continue lines.append(f"## {summary_title}") lines.append("") - non_enum_items = [m for m in items if m["kind"] != "enum"] - enum_items = [m for m in items if m["kind"] == "enum"] - if non_enum_items: + if kept_rows: lines += ["{.api-reference-table .api-function-table}", "| Return | Name | Description |", "|---|---|---|"] - for m in non_enum_items: + for m in kept_rows: ret = _md_escape_cell(m["type"]) if ret and m["static"]: ret = "static " + ret @@ -2012,6 +2038,8 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, "", ] + if inline: + return lines _stub_write(out, "\n".join(lines)) @@ -2347,6 +2375,87 @@ def _fallback_module_tree(name: str): } +def _apply_group_doc_override(tree: dict, xml_dir: pathlib.Path) -> None: + """Repair module groups whose subgroups were `@addtogroup`'d but never nested + under a titled `@defgroup`, which Doxygen leaves as orphan top-level groups + with an empty landing page. Injects the group description and attaches the + real subgroups as children (recursively retitled). Driven by + `_GROUP_DOC_OVERRIDES` / `_GROUP_TITLE_OVERRIDES`; no-op without an override.""" + ov = _GROUP_DOC_OVERRIDES.get(tree["name"]) + if not ov: + return + if ov.get("detailed") and not tree["detailed"]: + tree["detailed"] = ov["detailed"] + have = {c["name"] for c in tree["children"]} + for sub in ov.get("subgroups", ()): + if sub in have: + continue + child = _build_api_hierarchy("group__" + sub.replace("_", "__"), xml_dir) + if child is not None: + tree["children"].append(child) + + def _retitle(node: dict) -> None: + node["title"] = _GROUP_TITLE_OVERRIDES.get(node["name"], node["title"]) + for c in node.get("children", ()): + _retitle(c) + _retitle(tree) + + +def _harvest_namespace_into_group(tree: dict, xml_dir: pathlib.Path) -> None: + # Pull funcs/classes under the module's include prefix (per _GROUP_NS_HARVEST) + # into the otherwise-empty group node, from the cv namespace and any group + # they were filed under (e.g. depth.hpp uses @addtogroup rgbd). + import xml.etree.ElementTree as _ET + prefix = _GROUP_NS_HARVEST.get(tree["name"]) + if not prefix: + return + tree.setdefault("sections", {}) + have = {c["refid"] for c in tree["innerclasses"]} + + def _ingest(cd): + for title, members in _parse_member_sections(cd).items(): + seen = {m["id"] for m in tree["sections"].get(title, [])} + kept = [m for m in members + if (m.get("include_file") or "").startswith(prefix) + and m["id"] not in seen] + if kept: + tree["sections"].setdefault(title, []).extend(kept) + for ic in cd.findall("innerclass"): + rid = ic.get("refid", "") + if ic.get("prot") != "public" or not rid or rid in have: + continue + cx = xml_dir / f"{rid}.xml" + if not cx.is_file(): + continue + try: + ccd = _ET.parse(cx).getroot().find("compounddef") + except _ET.ParseError: + continue + loc = ccd.find("location") if ccd is not None else None + if loc is None or not (loc.get("file") or "").startswith(prefix): + continue + qualified = " ".join((ic.text or "").split()) + tree["innerclasses"].append({ + "refid": rid, "name": qualified, "qualified": qualified, + "kind": "struct" if rid.startswith("struct") else "class", + "brief": _read_class_brief(rid, xml_dir), + }) + have.add(rid) + + srcs = [xml_dir / "namespacecv.xml"] + srcs += [g for g in xml_dir.glob("group__*.xml") + if prefix in g.read_text(encoding="utf-8", errors="ignore")] + for s in srcs: + if not s.is_file(): + continue + try: + cd = _ET.parse(s).getroot().find("compounddef") + except _ET.ParseError: + continue + if cd is not None: + _ingest(cd) + + def _generate_api_stubs(modules, xml_dir, out_dir, root_anchor="api_root", root_title="API Reference", root_desc=None, extra_groups=()): @@ -2394,8 +2503,10 @@ def _generate_api_stubs(modules, xml_dir, out_dir, if out_dir.exists(): shutil.rmtree(out_dir) out_dir.mkdir(parents=True, exist_ok=True) - global _stub_written + global _stub_written, _MERGED_GROUPS, _MERGED_CLASS_TO_GROUP _stub_written = set() + _MERGED_GROUPS = [] + _MERGED_CLASS_TO_GROUP = {} _desc = root_desc or ( "Sphinx-rendered API reference. Each entry below is a module's " "umbrella `@defgroup`; sub-pages mirror the Doxygen subgroup hierarchy.") @@ -2414,6 +2525,10 @@ def _generate_api_stubs(modules, xml_dir, out_dir, _gapi_tree = None # saved to write gapi.md wrapper after all stubs for m in list(modules) + list(extra_groups): is_extra = m in extra_groups + if m in _GROUP_OVERRIDE_SUBGROUPS: + # Re-parented under its module by _apply_group_doc_override; skip so + # it isn't also emitted as a standalone orphan page. + continue stem = _module_group_stem(m) tree = _build_api_hierarchy("group__" + stem.replace("_", "__"), xml_dir) if tree is None: @@ -2422,6 +2537,8 @@ def _generate_api_stubs(modules, xml_dir, out_dir, tree = _fallback_module_tree(m) if tree is None: continue + _apply_group_doc_override(tree, xml_dir) + _harvest_namespace_into_group(tree, xml_dir) trees.append(tree) if is_extra: pass @@ -2493,8 +2610,51 @@ def _generate_api_stubs(modules, xml_dir, out_dir, for _cls in classes_seen.values(): _ANCHOR_TO_DOC.setdefault( _cls["refid"], f"{_doc_prefix}/{_class_page_name(_cls['refid'])}") + # Merged shell groups: a Classes box then each class inline. Done after + # seeding above so bases resolve for "inherited from" links. + for _mout, _mlines, _mclasses in _MERGED_GROUPS: + _lines = list(_mlines) + # Slug matching the per-class `## heading` its box entry links to. + def _cls_slug(_c): + _h = f"{_c['kind'].title()} {_c.get('qualified') or _c['name']}" + return re.sub(r"[^a-z0-9]+", "-", _h.lower()).strip("-") + _lines += ["## Classes", "", "{.api-reference-table}", + "| Name | Description |", "|---|---|"] + for _c in _mclasses: + # Encode "::" so the cv-linkifier doesn't nest an in our link. + _nm = _c["name"].replace("::", "::") + _lines.append( + f'| {_c["kind"]} ' + f'{_nm} ' + f"| {_md_escape_cell(_c.get('brief', ''))} |") + _lines.append("") + for _c in _mclasses: + _q = _c.get("qualified") or _c["name"] + _lines += [f"## {_c['kind'].title()} {_q}", ""] + _lines += _write_class_stub(_c, out_dir, xml_dir, inline=True) or [] + _stub_write(_mout, "\n".join(_lines) + "\n") # Per-class pages; seed `_ANCHOR_TO_DOC` refid→docname for `@ref`. for cls in classes_seen.values(): + _grp = _MERGED_CLASS_TO_GROUP.get(cls["refid"]) + if _grp: + # Inlined into its group page: turn this page into a redirect and + # aim refid xrefs at the group instead. + _rel = _grp.split("/", 1)[-1] + _stub_write( + out_dir / f"{_class_page_name(cls['refid'])}.md", + f"# {cls.get('qualified') or cls['name']}\n\n" + f'\n\n' + f'

This page has moved to ' + f'{_rel}.

\n') + _ANCHOR_TO_DOC[cls["refid"]] = _grp + _ALL_CLASSES[cls["refid"]] = { + "qualified": cls.get("qualified") or cls.get("name", ""), + "kind": cls.get("kind", "class"), + "brief": cls.get("brief", ""), + "docname": _grp, + } + continue _write_class_stub(cls, out_dir, xml_dir) _docname = f"{_doc_prefix}/{_class_page_name(cls['refid'])}" _ANCHOR_TO_DOC[cls["refid"]] = _docname diff --git a/docs_sphinx/conf_helpers/translate.py b/docs_sphinx/conf_helpers/translate.py index 53eecb4ef7..54c8c83902 100644 --- a/docs_sphinx/conf_helpers/translate.py +++ b/docs_sphinx/conf_helpers/translate.py @@ -15,6 +15,50 @@ def _normalize_lang(lang: str) -> str: return _LANG_ALIASES.get(lang, lang) +# Snippet-boundary marker lines (e.g. `// [name]`, `## [name]`), stripped +# from rendered code to mirror docs.opencv.org. Matches only a bare +# marker + `[name]` on its own line, so real code embedding a `[token]` +# is left alone. +_SNIPPET_MARKER_RE = re.compile( + r"^[ \t]*(?://!|//|##|#)[ \t]*\[[\w\- ]+\][ \t]*$" + r"|^[ \t]*[ \t]*$" +) + +# Doxygen block comments (`/** … */`, `/*! … */`) are decorative chrome in +# OpenCV samples; strip them to mirror docs.opencv.org. Plain `/* … */`, +# `//`, and `///` comments are kept as visible commentary. +# Line-block pass first removes whole-line blocks (consuming the trailing +# newline so no hollow blank remains); inline pass then sweeps the rare +# mid-line block without eating surrounding code. +_DOXY_LINE_BLOCK_RE = re.compile( + r"^[ \t]*/\*[*!].*?\*/[ \t]*\n?", + re.DOTALL | re.MULTILINE, +) +_DOXY_INLINE_BLOCK_RE = re.compile( + r"/\*[*!].*?\*/", + re.DOTALL, +) + + +def _strip_snippet_markers(text: str) -> str: + """Drop snippet-boundary marker lines from a code body (whole line + removed, no hollow blank left). Runs of 3+ resulting blank lines are + collapsed to 2, matching docs.opencv.org's rendering.""" + out = [ln for ln in text.split("\n") + if not _SNIPPET_MARKER_RE.match(ln)] + joined = "\n".join(out) + return re.sub(r"\n{3,}", "\n\n", joined) + + +def _strip_doxygen_block_comments(text: str) -> str: + """Drop every `/** … */` / `/*! … */` Doxygen block comment anywhere in + a snippet (with or without `@tag` directives). Plain `/* … */`, `//`, + and `///` comments stay intact.""" + text = _DOXY_LINE_BLOCK_RE.sub("", text) + text = _DOXY_INLINE_BLOCK_RE.sub("", text) + return text + + def _read_snippet(rel_path: str, label: str | None) -> tuple[str, str]: """Return (code_text, language) for an @include / @snippet directive.""" rel_norm = rel_path.lstrip("/") @@ -34,7 +78,11 @@ def _read_snippet(rel_path: str, label: str | None) -> tuple[str, str]: ".xml": "xml", ".html": "html", ".sh": "bash", ".bash": "bash"}.get(ext, "text") if label is None: - return text, lang + # `@include`: whole file. Strip Doxygen block comments and snippet + # markers so the rendered block matches docs.opencv.org (starts at + # the first `#include`/`import`, no banners or doc-headers). + return _strip_snippet_markers( + _strip_doxygen_block_comments(text)), lang # Match `[label]` after any comment marker. pat = re.compile(r"^[^\[\n]*(?://!|//|##|#|