From 04aee009aa90ace67c7259eaf570295381852fed Mon Sep 17 00:00:00 2001 From: omrope79 Date: Fri, 5 Jun 2026 16:48:27 +0530 Subject: [PATCH] Merge pull request #29220 from omrope79:doc_optimizations_v4 [FOLLOW UP] : Documentation optimizations for the new Sphinx structure #29220 ### Pull Request Readiness Checklist This PR serves as a follow-up to the new documentation system introduced in [#29206](https://github.com/opencv/opencv/pull/29206) Co-authored by: @abhishek-gola @kirtijindal14 @Akansha-977 @Prasadayus @varun-jaiswal17 See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request - [x] I agree to contribute to the project under Apache 2 License. - [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV - [x] The PR is proposed to the proper branch - [x] There is a reference to the original bug report and related work - [x] There is accuracy test, performance test and test data in opencv_extra repository, if applicable Patch to opencv_extra has the same branch name. - [x] The feature is well documented and sample code can be built with the project CMake --- doc/CMakeLists.txt | 3 + doc/header.html | 1 + .../js_assets/js_image_classification.html | 21 +- .../js_image_classification_model_info.json | 87 +- ...s_image_classification_webnn_polyfill.html | 21 +- .../js_image_classification_with_camera.html | 21 +- .../js_assets/js_object_detection.html | 29 +- .../js_object_detection_model_info.json | 41 +- .../js_object_detection_with_camera.html | 29 +- .../js_assets/js_pose_estimation.html | 21 +- .../js_pose_estimation_model_info.json | 24 +- .../js_assets/js_semantic_segmentation.html | 2 +- .../js_semantic_segmentation_model_info.json | 2 +- .../js_assets/js_style_transfer.html | 23 +- .../js_style_transfer_model_info.json | 10 +- .../dnn_custom_layers/dnn_custom_layers.md | 43 +- .../android_dnn_intro.markdown | 8 +- .../config_reference.markdown | 2 +- docs_sphinx/CMakeLists.txt | 47 +- docs_sphinx/_static/custom.css | 399 ++++- docs_sphinx/_static/opencv-logo.svg | 1 + docs_sphinx/_static/version.js | 135 ++ docs_sphinx/_templates/layout.html | 39 +- docs_sphinx/_templates/navbar-logo.html | 37 + .../_templates/opencv-version-switcher.html | 81 + docs_sphinx/conf.py | 13 +- docs_sphinx/conf_helpers/build.py | 40 +- docs_sphinx/conf_helpers/postprocess.py | 368 +++- docs_sphinx/conf_helpers/state.py | 206 ++- docs_sphinx/conf_helpers/stubs.py | 1485 ++++++++++++++++- docs_sphinx/conf_helpers/translate.py | 275 ++- docs_sphinx/conf_helpers/xml_render.py | 345 +++- .../dnn/include/opencv2/dnn/all_layers.hpp | 4 +- modules/dnn/include/opencv2/dnn/dnn.hpp | 1 - modules/dnn/perf/perf_net.cpp | 5 +- modules/dnn/src/net_impl2.cpp | 3 +- modules/java/jar/CMakeLists.txt | 12 +- samples/dnn/README.md | 8 +- samples/dnn/common.hpp | 4 +- samples/dnn/common.py | 4 +- samples/dnn/models.yml | 69 +- samples/dnn/object_detection.cpp | 90 +- samples/dnn/object_detection.py | 59 +- 43 files changed, 3468 insertions(+), 650 deletions(-) create mode 100644 docs_sphinx/_static/opencv-logo.svg create mode 100644 docs_sphinx/_static/version.js create mode 100644 docs_sphinx/_templates/navbar-logo.html create mode 100644 docs_sphinx/_templates/opencv-version-switcher.html diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt index 603365964a..0aee356f70 100644 --- a/doc/CMakeLists.txt +++ b/doc/CMakeLists.txt @@ -195,6 +195,9 @@ if(DOXYGEN_FOUND) list(APPEND CMAKE_DOXYGEN_HTML_FILES "${CMAKE_CURRENT_SOURCE_DIR}/bodybg.png") # list(APPEND CMAKE_DOXYGEN_HTML_FILES "${CMAKE_CURRENT_SOURCE_DIR}/mymath.sty") list(APPEND CMAKE_DOXYGEN_HTML_FILES "${CMAKE_CURRENT_SOURCE_DIR}/tutorial-utils.js") + # Version dropdown list (shared single source with the Sphinx 5.0 build). Copied + # next to the other doxygen assets so header.html can load it via $relpath^. + list(APPEND CMAKE_DOXYGEN_HTML_FILES "${CMAKE_CURRENT_SOURCE_DIR}/../docs_sphinx/_static/version.js") string(REPLACE ";" " \\\n" CMAKE_DOXYGEN_HTML_FILES "${CMAKE_DOXYGEN_HTML_FILES}") if (DOXYGEN_DOT_EXECUTABLE) diff --git a/doc/header.html b/doc/header.html index acc89667a7..14fec1a73f 100644 --- a/doc/header.html +++ b/doc/header.html @@ -18,6 +18,7 @@ + diff --git a/doc/js_tutorials/js_assets/js_image_classification.html b/doc/js_tutorials/js_assets/js_image_classification.html index f33109eeab..45c11893d6 100644 --- a/doc/js_tutorials/js_assets/js_image_classification.html +++ b/doc/js_tutorials/js_assets/js_image_classification.html @@ -11,7 +11,7 @@

Image Classification Example

This tutorial shows you how to write an image classification example with OpenCV.js.
- To try the example you should click the modelFile button(and configFile button if needed) to upload inference model. + To try the example you should click the modelFile button to upload an ONNX model file. You can find the model URLs and parameters in the model info section. Then You should change the parameters in the first code snippet according to the uploaded model. Finally click Try it button to see the result. You can choose any other images.
@@ -69,13 +69,6 @@ - - -

- configFile -
- - @@ -123,7 +116,7 @@ labelsUrl = "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dn main = async function() { const labels = await loadLables(labelsUrl); const input = getBlobFromImage(inputSize, mean, std, swapRB, 'canvasInput'); - let net = cv.readNet(configPath, modelPath); + let net = cv.readNet(modelPath); net.setInput(input); const start = performance.now(); const result = net.forward(); @@ -201,22 +194,12 @@ softmax = function(result) { loadImageToCanvas(e, 'canvasInput'); }); - let configPath = ""; - let configFile = document.getElementById('configFile'); - configFile.addEventListener('change', async (e) => { - initStatus(); - configPath = await loadModel(e); - document.getElementById('status').innerHTML = `The config file '${configPath}' is created successfully.`; - }); - let modelPath = ""; let modelFile = document.getElementById('modelFile'); modelFile.addEventListener('change', async (e) => { initStatus(); modelPath = await loadModel(e); document.getElementById('status').innerHTML = `The model file '${modelPath}' is created successfully.`; - configPath = ""; - configFile.value = ""; }); utils.loadOpenCv(() => { diff --git a/doc/js_tutorials/js_assets/js_image_classification_model_info.json b/doc/js_tutorials/js_assets/js_image_classification_model_info.json index e0f057b4aa..df1ccc683d 100644 --- a/doc/js_tutorials/js_assets/js_image_classification_model_info.json +++ b/doc/js_tutorials/js_assets/js_image_classification_model_info.json @@ -1,65 +1,72 @@ { - "caffe": [ + "onnx": [ { - "model": "alexnet", - "mean": "104, 117, 123", - "std": "1", - "swapRB": "false", - "needSoftmax": "false", - "labelsUrl": "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt", - "modelUrl": "http://dl.caffe.berkeleyvision.org/bvlc_alexnet.caffemodel", - "configUrl": "https://raw.githubusercontent.com/BVLC/caffe/master/models/bvlc_alexnet/deploy.prototxt" - }, - { - "model": "densenet", - "mean": "127.5, 127.5, 127.5", - "std": "0.007843", + "model": "googlenet", + "inputSize": "224, 224", + "mean": "103.939, 116.779, 123.675", + "std": "1, 1, 1", + "scale": "1", "swapRB": "false", "needSoftmax": "true", "labelsUrl": "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt", - "modelUrl": "https://drive.google.com/open?id=0B7ubpZO7HnlCcHlfNmJkU2VPelE", - "configUrl": "https://raw.githubusercontent.com/shicai/DenseNet-Caffe/master/DenseNet_121.prototxt" - }, - { - "model": "googlenet", - "mean": "104, 117, 123", - "std": "1", - "swapRB": "false", - "needSoftmax": "false", - "labelsUrl": "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt", - "modelUrl": "http://dl.caffe.berkeleyvision.org/bvlc_googlenet.caffemodel", - "configUrl": "https://raw.githubusercontent.com/BVLC/caffe/master/models/bvlc_googlenet/deploy.prototxt" + "modelUrl": "https://github.com/onnx/models/raw/69c5d3751dda5349fd3fc53f525395d180420c07/vision/classification/inception_and_googlenet/googlenet/model/googlenet-8.onnx" }, { "model": "squeezenet", - "mean": "104, 117, 123", - "std": "1", - "swapRB": "false", - "needSoftmax": "false", + "inputSize": "224, 224", + "mean": "0.485, 0.456, 0.406", + "std": "0.229, 0.224, 0.225", + "scale": "0.003921", + "swapRB": "true", + "needSoftmax": "true", "labelsUrl": "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt", - "modelUrl": "https://raw.githubusercontent.com/forresti/SqueezeNet/master/SqueezeNet_v1.0/squeezenet_v1.0.caffemodel", - "configUrl": "https://raw.githubusercontent.com/forresti/SqueezeNet/master/SqueezeNet_v1.0/deploy.prototxt" + "modelUrl": "https://github.com/onnx/models/raw/main/validated/vision/classification/squeezenet/model/squeezenet1.1-7.onnx?download=" }, { - "model": "VGG", - "mean": "104, 117, 123", - "std": "1", - "swapRB": "false", - "needSoftmax": "false", + "model": "resnet", + "inputSize": "224, 224", + "mean": "123.675, 116.28, 103.53", + "std": "58.395, 57.12, 57.375", + "scale": "1", + "swapRB": "true", + "needSoftmax": "true", "labelsUrl": "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt", - "modelUrl": "http://www.robots.ox.ac.uk/~vgg/software/very_deep/caffe/VGG_ILSVRC_19_layers.caffemodel", - "configUrl": "https://gist.githubusercontent.com/ksimonyan/3785162f95cd2d5fee77/raw/f02f8769e64494bcd3d7e97d5d747ac275825721/VGG_ILSVRC_19_layers_deploy.prototxt" + "modelUrl": "https://github.com/onnx/models/raw/main/validated/vision/classification/resnet/model/resnet50-v2-7.onnx" + }, + { + "model": "vgg16", + "inputSize": "224, 224", + "mean": "103.939, 116.779, 123.68", + "std": "1, 1, 1", + "scale": "1", + "swapRB": "false", + "needSoftmax": "true", + "labelsUrl": "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt", + "modelUrl": "https://github.com/onnx/models/raw/f884b33c3e2371952aad7ea091898f418c830fe5/vision/classification/vgg/model/vgg16-bn-7.onnx" + }, + { + "model": "densenet121", + "inputSize": "224, 224", + "mean": "123.675, 116.28, 103.53", + "std": "0.229, 0.224, 0.225", + "scale": "0.003921", + "swapRB": "true", + "needSoftmax": "true", + "labelsUrl": "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dnn/classification_classes_ILSVRC2012.txt", + "modelUrl": "https://github.com/onnx/models/raw/4eff8f9b9189672de28d087684e7085ad977747c/vision/classification/densenet-121/model/densenet-8.onnx" } ], "tensorflow": [ { "model": "inception", + "inputSize": "224, 224", "mean": "123, 117, 104", "std": "1", + "scale": "1", "swapRB": "true", "needSoftmax": "false", "labelsUrl": "https://raw.githubusercontent.com/petewarden/tf_ios_makefile_example/master/data/imagenet_comp_graph_label_strings.txt", "modelUrl": "https://raw.githubusercontent.com/petewarden/tf_ios_makefile_example/master/data/tensorflow_inception_graph.pb" } ] -} \ No newline at end of file +} diff --git a/doc/js_tutorials/js_assets/js_image_classification_webnn_polyfill.html b/doc/js_tutorials/js_assets/js_image_classification_webnn_polyfill.html index d798314b22..928c994988 100644 --- a/doc/js_tutorials/js_assets/js_image_classification_webnn_polyfill.html +++ b/doc/js_tutorials/js_assets/js_image_classification_webnn_polyfill.html @@ -12,7 +12,7 @@

Image Classification Example

This tutorial shows you how to write an image classification example with OpenCV.js.
- To try the example you should click the modelFile button(and configFile button if needed) to upload inference model. + To try the example you should click the modelFile button to upload an ONNX model file. You can find the model URLs and parameters in the model info section. Then You should change the parameters in the first code snippet according to the uploaded model. Finally click Try it button to see the result. You can choose any other images.
@@ -70,13 +70,6 @@ - - -

- configFile -
- - @@ -124,7 +117,7 @@ labelsUrl = "https://raw.githubusercontent.com/opencv/opencv/5.x/samples/data/dn main = async function() { const labels = await loadLables(labelsUrl); const input = getBlobFromImage(inputSize, mean, std, swapRB, 'canvasInput'); - let net = cv.readNet(configPath, modelPath); + let net = cv.readNet(modelPath); net.setPreferableBackend(6); net.setInput(input); let result = net.forward(); @@ -207,22 +200,12 @@ softmax = function(result) { loadImageToCanvas(e, 'canvasInput'); }); - let configPath = ""; - let configFile = document.getElementById('configFile'); - configFile.addEventListener('change', async (e) => { - initStatus(); - configPath = await loadModel(e); - document.getElementById('status').innerHTML = `The config file '${configPath}' is created successfully.`; - }); - let modelPath = ""; let modelFile = document.getElementById('modelFile'); modelFile.addEventListener('change', async (e) => { initStatus(); modelPath = await loadModel(e); document.getElementById('status').innerHTML = `The model file '${modelPath}' is created successfully.`; - configPath = ""; - configFile.value = ""; }); utils.loadOpenCv(() => { diff --git a/doc/js_tutorials/js_assets/js_image_classification_with_camera.html b/doc/js_tutorials/js_assets/js_image_classification_with_camera.html index fc8538d98a..103c46c5ba 100644 --- a/doc/js_tutorials/js_assets/js_image_classification_with_camera.html +++ b/doc/js_tutorials/js_assets/js_image_classification_with_camera.html @@ -11,7 +11,7 @@

Image Classification Example with Camera

This tutorial shows you how to write an image classification example with camera.
- To try the example you should click the modelFile button(and configFile button if needed) to upload inference model. + To try the example you should click the modelFile button to upload an ONNX model file. You can find the model URLs and parameters in the model info section. Then You should change the parameters in the first code snippet according to the uploaded model. Finally click Start/Stop button to start or stop the camera capture.
@@ -69,13 +69,6 @@ - - -

- configFile -
- - @@ -126,7 +119,7 @@ let cap = new cv.VideoCapture(video); main = async function(frame) { const labels = await loadLables(labelsUrl); const input = getBlobFromImage(inputSize, mean, std, swapRB, frame); - let net = cv.readNet(configPath, modelPath); + let net = cv.readNet(modelPath); net.setInput(input); const start = performance.now(); const result = net.forward(); @@ -203,22 +196,12 @@ softmax = function(result) { } }); - let configPath = ""; - let configFile = document.getElementById('configFile'); - configFile.addEventListener('change', async (e) => { - initStatus(); - configPath = await loadModel(e); - document.getElementById('status').innerHTML = `The config file '${configPath}' is created successfully.`; - }); - let modelPath = ""; let modelFile = document.getElementById('modelFile'); modelFile.addEventListener('change', async (e) => { initStatus(); modelPath = await loadModel(e); document.getElementById('status').innerHTML = `The model file '${modelPath}' is created successfully.`; - configPath = ""; - configFile.value = ""; }); utils.loadOpenCv(() => { diff --git a/doc/js_tutorials/js_assets/js_object_detection.html b/doc/js_tutorials/js_assets/js_object_detection.html index 82f110ca27..7857bb2d24 100644 --- a/doc/js_tutorials/js_assets/js_object_detection.html +++ b/doc/js_tutorials/js_assets/js_object_detection.html @@ -11,7 +11,7 @@

Object Detection Example

This tutorial shows you how to write an object detection example with OpenCV.js.
- To try the example you should click the modelFile button(and configFile button if needed) to upload inference model. + To try the example you should click the modelFile button to upload an ONNX model file. You can find the model URLs and parameters in the model info section. Then You should change the parameters in the first code snippet according to the uploaded model. Finally click Try it button to see the result. You can choose any other images.
@@ -45,13 +45,6 @@ - - -

- configFile -
- - @@ -84,8 +77,8 @@ + + diff --git a/docs_sphinx/conf.py b/docs_sphinx/conf.py index 4f0dfbf2a7..70a01589fe 100644 --- a/docs_sphinx/conf.py +++ b/docs_sphinx/conf.py @@ -24,7 +24,7 @@ from conf_helpers.postprocess import _inline_coll_graphs_on_finish # -- Project ---------------------------------------------------------------- project = "OpenCV" author = "OpenCV Team" -release = "5.x" +release = "5.0" # -- Sphinx core ------------------------------------------------------------ extensions = ["myst_parser"] @@ -143,7 +143,16 @@ html_css_files = [ "custom.css", ] html_theme_options = { - "logo": {"text": f"OpenCV {release}"}, + "logo": { + "text": f"OpenCV {release}", + "image_light": "_static/opencv-logo.svg", + "image_dark": "_static/opencv-logo.svg", + }, + # Navbar layout: logo on the left, version switcher right beside it — + # mirrors the legacy docs.opencv.org header where the version selector + # sits inline with the wordmark. The switcher template reads the shared + # /version.js (window.OPENCV_DOC_VERSIONS); no build-time list is generated. + "navbar_start": ["navbar-logo", "opencv-version-switcher"], "header_links_before_dropdown": 6, "external_links": [ {"docname": master_doc, "name": "Main Page"}, diff --git a/docs_sphinx/conf_helpers/build.py b/docs_sphinx/conf_helpers/build.py index d4b5d38d93..2bad665950 100644 --- a/docs_sphinx/conf_helpers/build.py +++ b/docs_sphinx/conf_helpers/build.py @@ -11,6 +11,37 @@ from .state import * from .xml_render import _patch_namespace_xml_for_breathe from .stubs import _generate_api_stubs + +def _discover_orphan_groups(xml_dir): + if not xml_dir.is_dir(): + return [], [] + folders = set() + for _root in (OPENCV_ROOT / "modules", CONTRIB_ROOT): + if _root.is_dir(): + folders.update(d.name for d in _root.iterdir() + if (d / "include" / "opencv2").is_dir()) + skip = set(folders) | {f.replace("_", "__") for f in folders} + skip |= {_module_group_stem(m) for m in folders} + all_groups, child = set(), set() + for gx in xml_dir.glob("group__*.xml"): + all_groups.add(gx.stem) + try: + xml = gx.read_text(encoding="utf-8", errors="ignore") + except OSError: + continue + child.update(re.findall(r' str: @@ -77,10 +78,14 @@ def _inline_collaboration_svgs(api_dir: pathlib.Path, return m.group(0) svg = href_re.sub(_rewrite_href, svg[start:]) # carry theme classes + alt for dark mode / a11y - return svg.replace( + svg = svg.replace( "{svg}' new = img_re.sub(_inline, text) if new != text: @@ -125,6 +130,63 @@ def _strip_breathe_class_clutter(api_dir: pathlib.Path) -> None: if new != text: h.write_text(new, encoding="utf-8") +def _fix_gapi_images(out_dir: pathlib.Path) -> None: + """Copy gapi doc images to _images/ and fix src paths in gapi.html.""" + gapi_html = out_dir / "extra_modules" / "gapi.html" + if not gapi_html.is_file(): + return + images_dir = out_dir / "_images" + images_dir.mkdir(exist_ok=True) + + text = gapi_html.read_text(encoding="utf-8") + + def _fix_src(m): + raw_path = m.group(1) + if "contrib_modules" not in raw_path: + return m.group(0) + # raw_path is relative to extra_modules/ in the browser, + # but the file lives at out_dir / raw_path (no extra_modules prefix) + src_file = out_dir / raw_path + if not src_file.is_file(): + return m.group(0) + dest = images_dir / src_file.name + if not dest.is_file(): + import shutil as _shutil + _shutil.copy2(src_file, dest) + return f'src="../_images/{src_file.name}"' + + new_text = re.sub(r'src="([^"]+)"', _fix_src, text) + if new_text != text: + gapi_html.write_text(new_text, encoding="utf-8") + +def _copy_js_tryit_files(out_dir: pathlib.Path) -> None: + """Copy js_*.html Try-it pages + assets so iframe src="../../js_*.html" resolves.""" + import shutil, os + js_assets = DOC_ROOT / "js_tutorials" / "js_assets" + dest = out_dir / "js_tutorials" + if not js_assets.is_dir() or not dest.is_dir(): + return + for src in js_assets.iterdir(): + if src.is_file(): + 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 = 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) + # Extra assets referenced by Try-it pages but not in js_assets/. + _opencv_root = DOC_ROOT.parent + for _name, _src in { + "box.mp4": _opencv_root / "samples/cpp/tutorial_code/calib3d/real_time_pose_estimation/Data/box.mp4", + "space_shuttle.jpg": DOC_ROOT / "tutorials/dnn/images/space_shuttle.jpg", + "roi.jpg": DOC_ROOT / "py_tutorials/py_core/py_basic_ops/images/roi.jpg", + }.items(): + if _src.is_file() and not (dest / _name).exists(): + shutil.copy2(_src, dest / _name) + def _generate_search_map(out_dir: pathlib.Path) -> None: """Write _static/search_map.js: stem→Sphinx-path for every built HTML page.""" @@ -142,6 +204,302 @@ def _generate_search_map(out_dir: pathlib.Path) -> None: lines.append("};") (out_dir / "_static" / "search_map.js").write_text("\n".join(lines), encoding="utf-8") +_SYM = r"(?:group__|classcv|structcv|unioncv|namespacecv)\w*\.html" + +def _localize_doxygen_links(out_dir: pathlib.Path) -> None: + """Point symbol-page links at the local Sphinx page when we built it: + docs.opencv.org URLs, and bare relative names that 404 off the API dir.""" + import os + skip = {"_static", "_sources", "_images", "_sphinx_design_static"} + page_paths: dict[str, str] = {} + for f in out_dir.rglob("*.html"): + rel = f.relative_to(out_dir) + if rel.parts and rel.parts[0] in skip: + continue + page_paths.setdefault(f.name, rel.as_posix()) + ext_re = re.compile( + r'(?P' + _SYM + r')(?:#(?P[\w:.-]+))?"') + # `#include` links -> local file-reference page (same Doxygen file stem). + inc_re = re.compile( + r'[^/"]+\.html)">(?P[^<]+)') + strip_re = re.compile( + r']*\bhref="(?:' + re.escape(DOXYGEN_BASE_URL) + r'|[^"]*doc/doxygen/html/)' + r'(?![^"]*javadoc)[^"]*"[^>]*>(?P[^<]*)') + + for html in out_dir.rglob("*.html"): + rel = html.relative_to(out_dir) + if rel.parts and rel.parts[0] in skip: + continue + text = html.read_text(encoding="utf-8") + cur = rel.parent.as_posix() + + def _href(local: str, anchor: str) -> str | None: + target = page_paths.get(local) + if not target: + return None + href = target if cur == "." else os.path.relpath(target, cur) + return f'href="{href}{anchor}"' + + def _ext(m: "re.Match") -> str: + member = _DOXY_ANCHOR_TO_MEMBER.get(m.group("frag") or "") + h = _href(_doxy_page_to_local(m.group("page")), + f"#{member}" if member else "") + if not h: + return m.group(0) + # Flip the now-local link from external to internal styling. + return f' str: + frag = m.group("frag") + return _href(m.group("page"), f"#{frag}" if frag else "") or m.group(0) + + def _inc(m: "re.Match") -> str: + h = _href(m.group("file"), "") + if not h: + return m.group(0) + return f'{m.group("path")}' + + new = inc_re.sub(_inc, bare_re.sub(_bare, ext_re.sub(_ext, text))) + new = strip_re.sub(lambda m: m.group("txt"), new) + if new != text: + html.write_text(new, encoding="utf-8") + + +def _drop_moved_stub_search_entries() -> None: + """Drop moved-tutorial stub pages from the Doxygen search index.""" + doxy_html = _API_XML_DIR.parent / "html" + search_dir = doxy_html / "search" + if not search_dir.is_dir(): + return + stubs = set() + for h in doxy_html.rglob("*table_of_content*.html"): + try: + if "has been moved to this page" in h.read_text(encoding="utf-8", errors="ignore"): + stubs.add(h.name) + except OSError: + pass + if not stubs: + return + for js in search_dir.glob("*.js"): + lines = js.read_text(encoding="utf-8", errors="ignore").splitlines(keepends=True) + # Drop only single-target stub entries; keep multi-target terms. + kept = [ln for ln in lines if not ( + ln.count("['../") == 1 and any(f"/{s}'" in ln for s in stubs))] + if len(kept) != len(lines): + js.write_text("".join(kept), encoding="utf-8") + + +# Pygments emits `NAME` (and `class="nc"`/`"nf"` +# for class/function tokens) inside its rendered `
` for every C++
+# identifier in a code block. The example pages, snippet pages, tutorial
+# samples, and any `:::{code-block} cpp` fence all go through Pygments,
+# so by the time the HTML is written the code blocks have full syntax
+# colouring but ZERO clickable tokens — `Mat`, `InputArray`,
+# `getOptimalDFTSize`, etc. are inert text.
+#
+# This pass wraps each such span in an `` when the token resolves via `_LOCAL_CLASS_URL` /
+# `_LOCAL_TYPEDEF_URL`, mirroring what the API-stub renderer already
+# does for inline ``. Idempotent: skips spans already
+# inside an ``.
+_PYG_IDENT_SPAN_RE = re.compile(
+    r'(?P)'
+    r'(?P[A-Za-z_][A-Za-z0-9_]*)'
+    r'(?P)'
+)
+
+# Pygments preprocessor-file-name span:
+#   `"opencv2/core.hpp"`  (or <…>)
+# The quote characters are HTML-escaped (`"` / `<` / `>`).
+# Capture the inner path so we can wrap it in an `` linking to the
+# local Doxygen file page (`_FILE_URL` map). The quote chars stay
+# outside the anchor — mirrors how the enum-detail `#include` line is
+# rendered (`#include opencv2/core.hpp`).
+_PYG_CPF_SPAN_RE = re.compile(
+    r'(?P)'
+    r'(?P"|<)'
+    r'(?P[A-Za-z0-9_./+\-]+\.[A-Za-z0-9]+)'
+    r'(?P"|>)'
+    r'(?P)'
+)
+
+
+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
+    anchors. The substitution is scoped to spans inside `
` so we
+    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):
+        return
+    if not html_dir.is_dir():
+        return
+    import os
+
+    def _resolve(name: str) -> str | None:
+        return _LOCAL_CLASS_URL.get(name) or _LOCAL_TYPEDEF_URL.get(name)
+
+    # `
` blocks only — keeps the substitution from touching + # inline `` runs that may appear in other contexts. + _PRE_BLOCK_RE = re.compile(r"
(.*?)
", re.DOTALL) + + # Relative path from each rendered `.html` file's directory to the + # Doxygen html tree (which sits alongside `docs_sphinx/html/` at + # `doc/doxygen/html/`). Reused per file so paths render with the + # right number of `../` segments regardless of subdir depth. + _DOXY_ROOT = html_dir.parent.parent / "doc" / "doxygen" / "html" + + def _doxy_rel(html_path: pathlib.Path, file_url: str) -> str: + target = _DOXY_ROOT / file_url + try: + return os.path.relpath(target, start=html_path.parent) + except ValueError: + return f"../../../doc/doxygen/html/{file_url}" + + def _wrap_span(m: re.Match) -> str: + name = m.group("name") + url = _resolve(name) + if not url: + return m.group(0) + return (f'' + f'{m.group("prefix")}{name}{m.group("suffix")}') + + def _wrap_cpf(m: re.Match, current_html: pathlib.Path) -> str: + path = m.group("path") + # Only opencv headers — ``, `` are not in + # `_FILE_URL` and stay plain. + file_url = _FILE_URL.get(path) + if not file_url: + return m.group(0) + href = _doxy_rel(current_html, file_url) + # Keep the opening/closing quotes outside the `` (so they + # render as plain `"` / `<`/`>`), and put the link on just + # the path text — same shape the enum-detail `#include` line + # already uses elsewhere. + return (f'{m.group("prefix")}{m.group("openq")}' + f'{path}' + f'{m.group("closeq")}{m.group("suffix")}') + + def _rewrite_pre(m: re.Match, current_html: pathlib.Path) -> str: + inner = m.group(1) + # Skip spans already wrapped: if `` immediately precedes + # the ``, leave it. The Pygments + # output doesn't generate `` itself, so the only place + # `` appears in the inner text is from a prior pass — we + # detect by scanning for `` pairs and only rewrite + # text outside them. + out: list[str] = [] + i, n = 0, len(inner) + while i < n: + if inner.startswith("", i) + if j < 0: + out.append(inner[i:]) + break + out.append(inner[i:j + 4]) + i = j + 4 + else: + k = inner.find("" + "".join(out) + "
" + + for html in html_dir.rglob("*.html"): + try: + text = html.read_text(encoding="utf-8") + except OSError: + continue + if "
" not in text:
+            continue
+        new_text = _PRE_BLOCK_RE.sub(
+            lambda m: _rewrite_pre(m, html), text)
+        if new_text != text:
+            try:
+                html.write_text(new_text, encoding="utf-8")
+            except OSError:
+                pass
+
+
+_INLINE_CODE_RE = re.compile(
+    r'(?P[^<]*?)'
+)
+_A_BLOCK_RE = re.compile(r']*>.*?', re.DOTALL)
+_INLINE_TOK_RE = re.compile(r'(?:cv::)?_?[A-Za-z][A-Za-z0-9_]*')
+
+
+def _linkify_inline_code(html_dir: pathlib.Path) -> None:
+    if not (_LOCAL_CLASS_URL or _LOCAL_TYPEDEF_URL):
+        return
+    if not html_dir.is_dir():
+        return
+
+    def _resolve(name: str) -> str | None:
+        bare = name[4:] if name.startswith("cv::") else name
+        return _LOCAL_CLASS_URL.get(bare) or _LOCAL_TYPEDEF_URL.get(bare)
+
+    def _anchor_text(tok: str) -> str:
+        return tok.replace("::", "::")
+
+    def _linkify_body(body: str) -> str:
+        def _sub(m: re.Match) -> str:
+            tok = m.group(0)
+            url = _resolve(tok)
+            if not url:
+                return tok
+            return (f'{_anchor_text(tok)}')
+        return _INLINE_TOK_RE.sub(_sub, body)
+
+    def _rewrite_code(m: re.Match) -> str:
+        body = m.group("body")
+        new_body = _linkify_body(body)
+        if new_body == body:
+            return m.group(0)
+        return (f''
+                f'{new_body}')
+
+    for html in html_dir.rglob("*.html"):
+        try:
+            text = html.read_text(encoding="utf-8")
+        except OSError:
+            continue
+        if 'class="docutils literal notranslate"' not in text:
+            continue
+        masked: list[str] = []
+        def _mask(m: re.Match) -> str:
+            masked.append(m.group(0))
+            return f"\x00A{len(masked) - 1}\x00"
+        masked_text = _A_BLOCK_RE.sub(_mask, text)
+        new_masked = _INLINE_CODE_RE.sub(_rewrite_code, masked_text)
+        if new_masked == masked_text:
+            continue
+        new_text = re.sub(
+            r"\x00A(\d+)\x00",
+            lambda mm: masked[int(mm.group(1))],
+            new_masked,
+        )
+        try:
+            html.write_text(new_text, encoding="utf-8")
+        except OSError:
+            pass
+
 
 def _inline_coll_graphs_on_finish(app, exception):
     """build-finished entry point."""
@@ -151,4 +509,10 @@ def _inline_coll_graphs_on_finish(app, exception):
     for _api in ("main_modules", "extra_modules"):
         _inline_collaboration_svgs(out / _api, out / "_images")
         _strip_breathe_class_clutter(out / _api)
+        _linkify_code_blocks(out)
+    _linkify_inline_code(out)
+    _localize_doxygen_links(out)
+    _drop_moved_stub_search_entries()
+    _copy_js_tryit_files(out)
+    _fix_gapi_images(out)
     _generate_search_map(out)
diff --git a/docs_sphinx/conf_helpers/state.py b/docs_sphinx/conf_helpers/state.py
index 781d000d0e..361aac27d3 100644
--- a/docs_sphinx/conf_helpers/state.py
+++ b/docs_sphinx/conf_helpers/state.py
@@ -56,21 +56,41 @@ CONTRIB_MODULES = ([m.strip() for m in _contrib_env.split(",") if m.strip()]
 
 # SCOPE — env OPENCV_API_MODULES (comma/semicolon); empty disables API pages
 def _discover_api_modules() -> list[str]:
-    """Main + contrib modules whose umbrella header declares `@defgroup`."""
-    found = []
-    # Scan both the main tree and opencv_contrib/modules.
+    """Main + contrib modules that declare an `@defgroup`, by module dir name.
+
+    The `@defgroup` is not always in the umbrella `opencv2/.hpp`: some
+    modules declare it in a sub-header (datasets/dataset.hpp,
+    quality/qualitybase.hpp, reg/map.hpp) or a differently-named top header
+    (dnn_objdetect's core_detect.hpp, cannops' cann.hpp). So scan every header
+    under each module's include/ tree, and use the module DIRECTORY name (which
+    equals the top `@defgroup` / `group__` by convention) as the id."""
+    found: set[str] = set()
     _roots = [OPENCV_ROOT / "modules"]
     if CONTRIB_ROOT.is_dir():
         _roots.append(CONTRIB_ROOT)
+
+    def _has_defgroup(hdr) -> bool:
+        try:
+            return "@defgroup" in hdr.read_text(encoding="utf-8", errors="ignore")
+        except OSError:
+            return False
+
     for _root in _roots:
-        for _hdr in _root.glob("*/include/opencv2/*.hpp"):
-            if _hdr.stem != _hdr.parents[2].name:   # only the umbrella header
+        if not _root.is_dir():
+            continue
+        for _mod in _root.iterdir():
+            _inc = _mod / "include" / "opencv2"
+            if not _inc.is_dir():
                 continue
-            try:
-                if "@defgroup" in _hdr.read_text(encoding="utf-8", errors="ignore"):
-                    found.append(_hdr.stem)
-            except OSError:
-                pass
+            # Umbrella / top-level headers first (cheap, the common case)…
+            if any(_has_defgroup(h) for h in sorted(_inc.glob("*.hpp"))):
+                found.add(_mod.name)
+                continue
+            # …else fall back to sub-directory headers.
+            for _h in _inc.rglob("*.hpp"):
+                if _has_defgroup(_h):
+                    found.add(_mod.name)
+                    break
     return sorted(found)
 
 
@@ -109,10 +129,10 @@ def _module_group_stem(m: str) -> str:
     # module folder (folder `3d` -> `@defgroup _3d`). Return that real stem.
     if (_API_XML_DIR / f"group__{m.replace('_', '__')}.xml").is_file():
         return m
-    for _root in (OPENCV_ROOT / "modules", CONTRIB_ROOT):
+    for _root in (OPENCV_ROOT / "modules", CONTRIB_ROOT / "modules", CONTRIB_ROOT):
         _hdr = _root / m / "include" / "opencv2" / f"{m}.hpp"
         if _hdr.is_file():
-            _mm = re.search(r"@defgroup\s+(\S+)",
+            _mm = re.search(r"[@\\]defgroup\s+(\S+)",
                             _hdr.read_text(encoding="utf-8", errors="ignore"))
             return _mm.group(1) if _mm else m
     return m
@@ -171,6 +191,9 @@ DOXYGEN_BASE_URL = (
     .rstrip("/") + "/")
 # First existing wins; env OPENCV_DOXYGEN_TAGFILE overrides
 _TAG_CANDIDATES = (
+    # Track cmake's actual build dir via the XML dir.
+    _API_XML_DIR.parent / "html" / "opencv.tag",
+    _API_XML_DIR.parent / "opencv.tag",
     HERE.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
     HERE.parent.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag",
     # extra build-dir layouts (vanilla, contrib, nested CI)
@@ -250,11 +273,15 @@ if _TAG_FILE.is_file():
                     )
             elif _kind in ("class", "struct"):
                 _full = _c.findtext("name") or ""
-                if _full.startswith("cv::"):
+                # Skip G-API/detail internals; a bare cv::Point isn't gapi::own::Point.
+                if _full.startswith("cv::") and not any(
+                        _x in _full for _x in ("::own::", "::wip::", "::detail::")):
                     _short = _full.split("::")[-1]
                     _af = _c.findtext("filename")
-                    if _short and _af:
-                        _CV_SYMBOL_URL.setdefault(_short, DOXYGEN_BASE_URL + _af)
+                    # Prefer canonical cv:: over nested homonyms.
+                    if _short and _af and (
+                            _short not in _CV_SYMBOL_URL or _full == f"cv::{_short}"):
+                        _CV_SYMBOL_URL[_short] = DOXYGEN_BASE_URL + _af
             elif _kind == "group":
                 # module pages are kind="group"
                 _n = _c.findtext("name")
@@ -290,6 +317,32 @@ if _TAG_FILE.is_file():
     except Exception:
         pass
 
+# Canonical public header for each optional module that may be absent from a
+# build's Doxygen group XML. Ensure a `_FILE_URL` entry exists so the umbrella
+# `#include` on the module's placeholder page resolves (and a file-reference
+# page is generated for it). `setdefault` → a real tag entry always wins.
+_FALLBACK_MODULE_HEADERS: dict[str, str] = {
+    "datasets":      "opencv2/datasets/dataset.hpp",
+    "dnn_objdetect": "opencv2/core_detect.hpp",
+    "quality":       "opencv2/quality.hpp",
+    "reg":           "opencv2/reg/map.hpp",
+    "cannops":       "opencv2/cann.hpp",
+}
+# Every header path referenced by a fallback page (umbrella + per-function
+# includes), ensured present in `_FILE_URL` so each `#include` resolves to a
+# generated file-reference page. `setdefault` → real tag entries always win.
+_FALLBACK_FILE_HEADERS: list[str] = [
+    "opencv2/datasets/dataset.hpp", "opencv2/datasets/util.hpp",
+    "opencv2/core_detect.hpp",
+    "opencv2/quality.hpp", "opencv2/quality/qualitybase.hpp",
+    "opencv2/reg/map.hpp", "opencv2/reg/mapper.hpp",
+    "opencv2/cann.hpp", "opencv2/cann_interface.hpp",
+]
+for _inc in list(_FALLBACK_MODULE_HEADERS.values()) + _FALLBACK_FILE_HEADERS:
+    # Doxygen file-page stem: `.`->`_8`, `_`->`__` on the bare filename.
+    _fn = _inc.rsplit("/", 1)[-1].replace("_", "__").replace(".", "_8")
+    _FILE_URL.setdefault(_inc, f"{_fn}.html")
+
 def _doxygen_url(page: str) -> str:
     return DOXYGEN_BASE_URL + _TAG_FILENAMES.get(page, page)
 
@@ -346,11 +399,15 @@ if _LIVE_TAG_FILE.is_file():
 
 # -- Local-link variants of the maps above (step 8g) ------------------------
 _LOCAL_SRC_TAG = _TAG_FILE if _TAG_FILE.is_file() else _LIVE_TAG_FILE
-_LOCAL_CLASS_URL: dict[str, str] = {
-    # _Tp template-parameter placeholder stub
-    "_Tp": "class_Tp.html",
-}
-_LOCAL_TYPEDEF_URL: dict[str, str] = {}  # 'uchar' -> 'core_hal_interface.html#_CPPv45uchar'
+_LOCAL_CLASS_URL: dict[str, str] = {}
+# NOTE: `_Tp` (the template-parameter placeholder) used to map to
+# `class_Tp.html`, but no stub page is actually written for it — every
+# `` was a 404. Removing the entry leaves `_Tp`
+# tokens as plain (non-clickable) text in signatures, matching the
+# "no 404 clickables" requirement.
+_LOCAL_TYPEDEF_URL: dict[str, str] = {}  # 'uchar' -> 'core_hal_interface.html#uchar'
+# Group-enum refid -> page#anchor; lets enum-VALUE s link to their enum doc.
+_ENUM_REF_URL: dict[str, str] = {}
 # Doxygen template-param placeholder pages -> (display name, Sphinx page).
 # Sphinx mirrors these as stubs (stubs._write_placeholder_stubs) so diagram
 # cross-links resolve instead of 404ing.
@@ -361,6 +418,7 @@ _PLACEHOLDER_STUBS: dict[str, tuple[str, str]] = {
 # Doxygen compound filename -> Sphinx page filename (class/struct compounds).
 _LOCAL_PAGE_BY_DOXY_FILE: dict[str, str] = {
     _doxy: _page for _doxy, (_disp, _page) in _PLACEHOLDER_STUBS.items()}
+_cls_cands: dict[str, set] = {}  # short name -> set of full names (collision check)
 if _LOCAL_SRC_TAG.is_file():
     try:
         import xml.etree.ElementTree as _ET
@@ -372,7 +430,12 @@ if _LOCAL_SRC_TAG.is_file():
                     _short = _n.split("::")[-1]
                     _fn = _f if _f.endswith(".html") else _f + ".html"
                     _doxy_base = pathlib.PurePosixPath(_fn).name
-                    _LOCAL_CLASS_URL.setdefault(_short, _doxy_base)
+                    # Skip G-API/detail internals as bare `cv::` targets.
+                    if not any(_x in _n for _x in ("::own::", "::wip::", "::detail::")):
+                        _cls_cands.setdefault(_short, set()).add(_n)
+                        # Prefer canonical cv:: over nested homonyms.
+                        if _short not in _LOCAL_CLASS_URL or _n == f"cv::{_short}":
+                            _LOCAL_CLASS_URL[_short] = _doxy_base
                     # Sphinx mirrors Doxygen's filename except the few remapped.
                     _LOCAL_PAGE_BY_DOXY_FILE.setdefault(
                         _doxy_base, _LOCAL_CLASS_URL.get(_short, _doxy_base))
@@ -389,9 +452,19 @@ if _LOCAL_SRC_TAG.is_file():
                     continue
                 _mn = (_mem.findtext("name") or "").strip()
                 _maf = (_mem.findtext("anchorfile") or "").strip()
+                _man = (_mem.findtext("anchor") or "").strip()
                 if not (_mn and _maf):
                     continue
-                if _mn in _LOCAL_TYPEDEF_URL:
+                # The tagfile emits each typedef twice: once with the
+                # bare name (`InputArray`) and once fully-qualified
+                # (`cv::InputArray`). Normalize to the bare short name
+                # so we don't end up with a second map entry whose
+                # anchor literally contains `cv::` (e.g.
+                # `core_basic.html#cv::inputarray`) — that anchor never
+                # exists on the rendered page and every `cv::InputArray`
+                # codespan ends up 404-ing.
+                _short_mn = _mn.split("::")[-1]
+                if _short_mn in _LOCAL_TYPEDEF_URL:
                     continue   # first-occurrence wins
                 _bn = pathlib.PurePosixPath(_maf).name
                 if _bn.startswith("group__"):
@@ -404,14 +477,50 @@ if _LOCAL_SRC_TAG.is_file():
                     _local_page = "core_basic.html"
                 else:
                     _local_page = _bn
-                # HAL typedefs are global C; else cv::-scoped (cpp-domain v4 anchor)
-                if "hal_interface" in _local_page:
-                    _anchor = f"_CPPv4{len(_mn)}{_mn}"
+                # Anchor: mirror exactly the MyST target the detail block
+                # emits, since docutils' make_id lowercases the label and
+                # collapses every run of non-alnum chars to a single "-".
+                # The label differs by kind/where it renders:
+                #
+                #  * typedefs, namespace variables, and CLASS-member enums
+                #    render via `_render_member_detail` / the class-enum
+                #    path, both keyed by the Doxygen member refid
+                #    `({m['id']})=` (= "_1").
+                #    (The old `_CPPv4…` cpp-domain id was never emitted for
+                #    these hand-rolled markdown sections, so every such
+                #    cross-reference 404'd.)
+                #
+                #  * GROUP/namespace enums render (stubs, kind_key=="enum")
+                #    with `({_sphinx_cpp_v4_id(qualified)})=` instead — the
+                #    member refid is NOT used — so their anchor is the
+                #    mangled, lowercased cpp-v4 id, not the refid.
+                _is_class_pg = _bn.startswith(("class", "struct", "union"))
+                if _mk == "enumeration" and not _is_class_pg:
+                    _ns = (_c.findtext("name") or "").strip()
+                    _qual = _mn if "::" in _mn else (
+                        f"{_ns}::{_mn}" if _ns and _c.get("kind") == "namespace"
+                        else _mn)
+                    # _sphinx_cpp_v4_id: "_CPPv4N" + join(f"{len(p)}{p}") + "E"
+                    _label = ("_CPPv4N"
+                              + "".join(f"{len(_p)}{_p}" for _p in _qual.split("::"))
+                              + "E")
                 else:
-                    _anchor = f"_CPPv4N2cv{len(_mn)}{_mn}E"
+                    _stem = _bn[:-5] if _bn.endswith(".html") else _bn
+                    _label = f"{_stem}_1{_man}" if _man else _stem
+                _anchor = re.sub(r"[^a-z0-9]+", "-", _label.lower()).strip("-")
                 _LOCAL_TYPEDEF_URL[_mn] = f"{_local_page}#{_anchor}"
+                # Index group enums by refid so enum-VALUE s can resolve here.
+                if _mk == "enumeration" and not _is_class_pg and _man:
+                    _est = _bn[:-5] if _bn.endswith(".html") else _bn
+                    _ENUM_REF_URL.setdefault(f"{_est}_1{_man}",
+                                             f"{_local_page}#{_anchor}")
     except Exception:
         pass
+    # Drop ambiguous short names (>1 class, no canonical cv::) so a bare
+    # `cv::Point` stays plain text instead of linking to a wrong homonym.
+    for _s, _cands in _cls_cands.items():
+        if len(_cands) > 1 and f"cv::{_s}" not in _cands:
+            _LOCAL_CLASS_URL.pop(_s, None)
 
 
 def _doxy_page_to_local(basename: str) -> str:
@@ -488,27 +597,41 @@ def _func_slug(name: str) -> str:
 # ---- Citation numbering --------------------------------------------------
 # @cite KEY -> [N], N = bibtex-plain sort position
 def _bib_parse(text: str) -> list[dict]:
-    """Walk a BibTeX file into a list of {_type, _key, field: value, ...}."""
+    """Walk a BibTeX file into a list of {_type, _key, field: value, ...}.
+
+    Tolerant of a missing closing `}` (as bibtex/Doxygen are): a new
+    `@type{key,` at the start of a line implicitly closes the previous entry,
+    so one malformed record can't swallow the rest of the file. (e.g.
+    ximgproc.bib's `akinlar201782` has no closing brace upstream.)"""
     out: list[dict] = []
-    n, i = len(text), 0
-    while i < n:
-        m = re.search(r"@(\w+)\s*\{\s*([^\s,]+)\s*,", text[i:])
+    n = len(text)
+    header = re.compile(r"@(\w+)\s*\{\s*([^\s,]+)\s*,")
+    # Every entry header that begins a line is a hard boundary.
+    starts = [m.start() for m in
+              re.finditer(r"(?m)^[^\S\n]*@\w+\s*\{\s*[^\s,]+\s*,", text)]
+    for idx, s in enumerate(starts):
+        m = header.match(text, s)
         if not m:
-            break
+            continue
         kind, key = m.group(1), m.group(2)
-        i += m.end()
-        depth, body_start = 1, i
-        while i < n and depth > 0:
+        body_start = m.end()
+        limit = starts[idx + 1] if idx + 1 < len(starts) else n
+        # Matching close within this entry's span; if unbalanced (missing `}`),
+        # fall back to the next entry's boundary, trimming a trailing brace.
+        depth, i, end = 1, body_start, None
+        while i < limit:
             c = text[i]
             if c == "{":
                 depth += 1
             elif c == "}":
                 depth -= 1
+                if depth == 0:
+                    end = i
+                    break
             i += 1
-        if depth != 0:
-            break  # malformed entry
-        out.append({"_type": kind.lower(), "_key": key,
-                    **_bib_fields(text[body_start:i - 1])})
+        body = (text[body_start:end] if end is not None
+                else text[body_start:limit].rstrip().rstrip("}"))
+        out.append({"_type": kind.lower(), "_key": key, **_bib_fields(body)})
     return out
 
 def _bib_fields(body: str) -> dict[str, str]:
@@ -764,6 +887,7 @@ def _resolve_redirect(anchor: str) -> str:
 _ANCHOR_TO_DOC: dict[str, str] = {}            # anchor -> docname (internal)
 _ANCHOR_TO_EXTERNAL: dict[str, tuple[str, str]] = {}  # anchor -> (title, url)
 _ANCHOR_TO_TITLE: dict[str, str] = {}          # anchor -> first-heading title
+_LOCAL_MEMBER_IDS: set[str] = set()            # member detail targets we emit
 # anchors reachable via @subpage/@ref; rest are orphans
 _REFERENCED_ANCHORS: set[str] = set()
 
@@ -871,15 +995,15 @@ __all__ = [
     "HAVE_SPHINX_DESIGN", "HAVE_BREATHE",
     "DOXYGEN_BASE_URL", "_doxygen_url",
     "_TAG_FILE", "_TAG_FILENAMES", "_TAG_TITLES", "_DOC_PAGE_TITLES",
-    "_CV_SYMBOL_URL", "_FILE_URL",
+    "_CV_SYMBOL_URL", "_FILE_URL", "_FALLBACK_MODULE_HEADERS",
     "_CALL_GRAPH_ANCHORS", "_DOXY_ANCHOR_TO_MEMBER", "_norm_args",
     "_LIVE_GROUP_URL", "_LIVE_CLASS_URL", "_LIVE_TYPEDEF_URL",
-    "_LOCAL_CLASS_URL", "_LOCAL_TYPEDEF_URL", "_CLASS_TEMPLATE_DISPLAY",
+    "_LOCAL_CLASS_URL", "_LOCAL_TYPEDEF_URL", "_ENUM_REF_URL", "_CLASS_TEMPLATE_DISPLAY",
     "_LOCAL_PAGE_BY_DOXY_FILE", "_PLACEHOLDER_STUBS", "_doxy_page_to_local",
     "_func_slug",
     "_CITE_NUMBER", "_BIB_ENTRIES_SORTED", "_bib_render_all",
     "_REDIRECT_MAP", "_resolve_redirect",
-    "_ANCHOR_TO_DOC", "_ANCHOR_TO_EXTERNAL", "_ANCHOR_TO_TITLE",
+    "_ANCHOR_TO_DOC", "_ANCHOR_TO_EXTERNAL", "_ANCHOR_TO_TITLE", "_LOCAL_MEMBER_IDS",
     "_REFERENCED_ANCHORS", "_HEAD_RE",
     "_scan_internal", "_scan_external",
     "_IMAGE_INDEX", "_SNIPPET_INDEX", "_SNIPPET_BASES",
diff --git a/docs_sphinx/conf_helpers/stubs.py b/docs_sphinx/conf_helpers/stubs.py
index 2753d2d29b..ce230cf731 100644
--- a/docs_sphinx/conf_helpers/stubs.py
+++ b/docs_sphinx/conf_helpers/stubs.py
@@ -6,7 +6,7 @@
 
 """API-reference stub writers. Entry point: ``_generate_api_stubs``."""
 from __future__ import annotations
-import pathlib, os as _os, shutil as _shutil, textwrap as _textwrap
+import pathlib, os as _os, re, shutil as _shutil, textwrap as _textwrap
 from .state import *
 from .xml_render import *
 from .examples import (
@@ -14,6 +14,103 @@ from .examples import (
 )
 
 
+# xphoto TonemapDurand function documentation enhancements
+_XPHOTO_DOCS = {
+    "getContrast": {
+        "detailed": "Retrieves the resulting contrast on logarithmic scale, computed as log(max / min), where max and min are maximum and minimum luminance values of the resulting image.",
+        "returns": "resulting contrast on logarithmic scale, i. e. log(max / min), where max and min are maximum and minimum luminance values of the resulting image.",
+        "sig_prefix": "virtual ", "sig_suffix": " const = 0",
+    },
+    "setContrast": {
+        "detailed": "Sets the resulting contrast on logarithmic scale for the tone mapping algorithm.",
+        "params": [("contrast", "resulting contrast on logarithmic scale, i. e. log(max / min), where max and min are maximum and minimum luminance values of the resulting image.")],
+        "sig_prefix": "virtual ", "sig_suffix": " = 0",
+    },
+    "getSaturation": {
+        "detailed": "Retrieves the current saturation enhancement value. See createTonemapDrago for details.",
+        "returns": "saturation enhancement value. See createTonemapDrago",
+        "sig_prefix": "virtual ", "sig_suffix": " const = 0",
+    },
+    "setSaturation": {
+        "detailed": "Sets the saturation enhancement value for the tone mapping algorithm.",
+        "params": [("saturation", "saturation enhancement value. See createTonemapDrago")],
+        "sig_prefix": "virtual ", "sig_suffix": " = 0",
+    },
+    "getSigmaSpace": {
+        "detailed": "Retrieves the sigma parameter for the bilateral filter in coordinate space.",
+        "returns": "bilateral filter sigma in coordinate space",
+        "sig_prefix": "virtual ", "sig_suffix": " const = 0",
+    },
+    "setSigmaSpace": {
+        "detailed": "Sets the sigma parameter for the bilateral filter in coordinate space.",
+        "params": [("sigma_space", "bilateral filter sigma in coordinate space")],
+        "sig_prefix": "virtual ", "sig_suffix": " = 0",
+    },
+    "getSigmaColor": {
+        "detailed": "Retrieves the sigma parameter for the bilateral filter in color space.",
+        "returns": "bilateral filter sigma in color space",
+        "sig_prefix": "virtual ", "sig_suffix": " const = 0",
+    },
+    "setSigmaColor": {
+        "detailed": "Sets the sigma parameter for the bilateral filter in color space.",
+        "params": [("sigma_color", "bilateral filter sigma in color space")],
+        "sig_prefix": "virtual ", "sig_suffix": " = 0",
+    },
+}
+
+
+def _enhance_xphoto_member(m: dict, class_name: str = "") -> dict:
+    """Enhance xphoto TonemapDurand function documentation with detailed descriptions
+    and full C++ qualifiers (virtual, const = 0) injected at stub generation time.
+
+    Works for both class page (class_name provided) and module page (derive class
+    from the definition field when qualifiedname is missing in the group XML)."""
+    # Derive class name from definition when qualifiedname is absent (group XML)
+    if not class_name:
+        defn = m.get("definition") or ""
+        # definition looks like: "virtual float cv::xphoto::TonemapDurand::getContrast"
+        # strip return type tokens to get the qualified function name
+        parts = defn.split("::")
+        if len(parts) >= 3:
+            func_part = parts[-1]  # e.g. "getContrast"
+            class_name = "::".join(parts[:-1]).split()[-1] + "::" + "::".join(parts[1:-1])
+            # Simpler: extract class from definition by splitting on "::"
+            # "virtual float cv::xphoto::TonemapDurand::getContrast" -> "cv::xphoto::TonemapDurand"
+            qualified_parts = defn.rsplit("::", 1)
+            if len(qualified_parts) == 2:
+                class_name = qualified_parts[0].split()[-1]  # last token before ::name
+
+    if class_name != "cv::xphoto::TonemapDurand":
+        return m
+
+    func_name = m.get("name", "")
+    if func_name not in _XPHOTO_DOCS:
+        return m
+
+    m = dict(m)
+    docs = _XPHOTO_DOCS[func_name]
+
+    if "detailed" in docs and not m.get("detailed"):
+        m["detailed"] = docs["detailed"]
+    if "returns" in docs and not m.get("returns"):
+        m["returns"] = docs["returns"]
+    if "params" in docs and not m.get("params"):
+        m["params"] = docs["params"]
+    if "sig_prefix" in docs:
+        m["sig_prefix"] = docs["sig_prefix"]
+    if "sig_suffix" in docs:
+        m["sig_suffix"] = docs["sig_suffix"]
+
+    # For module page: qualified name is missing, use definition to set full_name
+    if not m.get("qualified") or "::" not in m.get("qualified", ""):
+        defn = m.get("definition") or ""
+        if "::" in defn:
+            # "virtual float cv::xphoto::TonemapDurand::getContrast" -> "cv::xphoto::TonemapDurand::getContrast"
+            m["qualified"] = defn.split()[-1]
+
+    return m
+
+
 # Drives write-if-changed and the stale-file sweep.
 _stub_written: set[pathlib.Path] = set()
 
@@ -28,6 +125,392 @@ _DOXY_HTML_ROOT: pathlib.Path | None = None
 _API_OUT_DIR: pathlib.Path | None = None
 
 
+def _include_page_href(inc: str) -> str | None:
+    """Link a `#include <...>` to the SPHINX file-reference page generated for
+    that header (`_write_file_ref_stubs`), which inlines the include-dependency
+    graph — a NEW Sphinx-format diagram page, never a Doxygen page. The page is
+    a sibling in the same api dir (named after the Doxygen file stem, e.g.
+    `ios_8h.html`). Returns None when the file isn't in the tag file."""
+    rel = _FILE_URL.get(inc)
+    return rel.rsplit("/", 1)[-1] if rel else None
+
+
+_MEMBER_ANCHOR_RE = re.compile(r"_1[a-z][A-Za-z0-9]*$")
+_SIG_WORD = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_:")
+# Module dir of the page currently being written (set by _generate_api_stubs)
+# so a signature `` link can be made relative to it.
+_CUR_MODULE_DIR = ""
+
+
+def _rel_doc_url(refid: str):
+    """Relative `.html` URL from the current module page to a signature
+    ``'s page: the compound's own page, or — for a member ref (enum value,
+    method) — its enclosing compound's page. Handles cross-module links."""
+    dn = _ANCHOR_TO_DOC.get(refid)
+    if not dn:
+        dn = _ANCHOR_TO_DOC.get(_MEMBER_ANCHOR_RE.sub("", refid))
+    if not dn:
+        return None
+    tgt_dir, _, tgt_file = dn.rpartition("/")
+    if not tgt_dir or tgt_dir == _CUR_MODULE_DIR:
+        return f"{tgt_file or dn}.html"
+    return f"../{tgt_dir}/{tgt_file}.html"
+
+
+def _sig_html_with_links(line: str, url_by_text: dict):
+    """HTML for a signature line, each known ref token wrapped in a link.
+    Matches only at token boundaries (so a return-type token isn't re-linked
+    inside the function's own `::`-qualified name). Returns None when no token
+    matched — the caller then keeps a plain markdown code span."""
+    import html as _html
+    spans: list = []
+    for text in sorted(url_by_text, key=len, reverse=True):
+        start = 0
+        while True:
+            i = line.find(text, start)
+            if i < 0:
+                break
+            j = i + len(text)
+            start = j
+            if (i == 0 or line[i - 1] not in _SIG_WORD) and \
+               (j >= len(line) or line[j] not in _SIG_WORD) and \
+               not any(s < j and i < e for s, e, _ in spans):
+                spans.append((i, j, url_by_text[text]))
+    if not spans:
+        return None
+    spans.sort()
+    out, last = [], 0
+    for s, e, url in spans:
+        out.append(_html.escape(line[last:s]))
+        out.append(f''
+                   f'{_html.escape(line[s:e])}')
+        last = e
+    out.append(_html.escape(line[last:]))
+    return "".join(out)
+
+
+_INCBY_CACHE: dict = {}
+
+
+def _included_by_map(xml_dir: pathlib.Path) -> tuple:
+    """Reverse every file compound's `` into an included-by map.
+
+    The Doxygen XML here was generated with INCLUDED_BY_GRAPH=NO, so it carries
+    no ``; derive the relationship from `` instead.
+    Returns (rev, stem_path): rev[stem] = set of stems that `#include` it;
+    stem_path[stem] = its display include-path. Cached per xml_dir."""
+    import xml.etree.ElementTree as _ET
+    key = str(xml_dir)
+    if key in _INCBY_CACHE:
+        return _INCBY_CACHE[key]
+    rev: dict = {}
+    stem_path: dict = {}
+    for fx in xml_dir.glob("*_8*.xml"):          # `_8` encodes the `.` of a file
+        try:
+            cd = _ET.parse(str(fx)).getroot().find("compounddef")
+        except _ET.ParseError:
+            continue
+        if cd is None or cd.get("kind") != "file":
+            continue
+        f_stem = cd.get("id") or fx.stem
+        loc = cd.find("location")
+        fpath = (loc.get("file") if loc is not None else "") or ""
+        j = fpath.find("opencv2/")
+        stem_path[f_stem] = (fpath[j:] if j >= 0
+                             else (cd.findtext("compoundname") or f_stem))
+        for inc in cd.findall("includes"):
+            iid = inc.get("refid")
+            if iid:
+                rev.setdefault(iid, set()).add(f_stem)
+                stem_path.setdefault(iid, (inc.text or "").strip())
+    _INCBY_CACHE[key] = (rev, stem_path)
+    return rev, stem_path
+
+
+def _generate_dep_graph_svg(stem: str, rev: dict, stem_path: dict,
+                            out_dir: pathlib.Path,
+                            max_nodes: int = 48):
+    """Build the included-by graph for `stem` with graphviz `dot`, since Doxygen
+    emitted no `*__dep.svg`. Walks UP the reverse-include graph (capped), writes
+    `{stem}__dep.svg` to out_dir in a Doxygen-like style, and returns it — or
+    None when there are no includers / dot is unavailable. Box-links point at
+    the sibling Sphinx file pages and are rewritten by the build-finished step
+    exactly like the include graph's links."""
+    import subprocess, hashlib as _hl
+    if not rev.get(stem):
+        return None
+    nodes = {stem}
+    edges: set = set()
+    frontier = [stem]
+    while frontier and len(nodes) <= max_nodes:
+        x = frontier.pop(0)
+        for inc in sorted(rev.get(x, ())):
+            edges.add((inc, x))
+            if inc not in nodes:
+                nodes.add(inc)
+                frontier.append(inc)
+    if len(nodes) <= 1:
+        return None
+
+    def _nid(s: str) -> str:
+        return "n" + _hl.md5(s.encode("utf-8")).hexdigest()[:10]
+    dot = [
+        'digraph "incby" {',
+        '  bgcolor="transparent";',
+        '  edge [color="#1868b4", arrowsize="0.7"];',
+        '  node [shape=box, style=filled, fillcolor="white", color="#3f3f3f",'
+        ' fontname="Helvetica", fontsize="10", height="0.2",'
+        ' margin="0.11,0.04"];',
+        '  rankdir="BT";',                       # file on top, includers below
+    ]
+    for n in sorted(nodes):
+        label = stem_path.get(n, n).replace('"', "")
+        if n == stem:
+            attrs = f'label="{label}", fillcolor="#bfbfbf"'   # the file itself
+        else:
+            attrs = f'label="{label}", URL="{n}.html"'
+        dot.append(f"  {_nid(n)} [{attrs}];")
+    for a, b in sorted(edges):
+        dot.append(f"  {_nid(a)} -> {_nid(b)};")
+    dot.append("}")
+    try:
+        res = subprocess.run(["dot", "-Tsvg"],
+                             input="\n".join(dot).encode("utf-8"),
+                             capture_output=True)
+    except (OSError, subprocess.SubprocessError):
+        return None
+    if res.returncode != 0 or not res.stdout:
+        return None
+    svg_path = out_dir / f"{stem}__dep.svg"
+    try:
+        svg_path.write_bytes(res.stdout)
+    except OSError:
+        return None
+    return svg_path
+
+
+def _write_file_ref_stubs(out_dir: pathlib.Path, xml_dir: pathlib.Path) -> None:
+    """One Sphinx file-reference page per header (title + its include-dependency
+    graph), so `#include` links land on a Sphinx diagram page. The graph SVG is
+    taken from the Doxygen build and inlined via the SAME machinery as class
+    collaboration diagrams (`_diagram_svg_lines` + the build-finished
+    `_inline_collaboration_svgs`, which also rewrites the graph's box-links to
+    local pages). Generated for every header in the tag file so the graph's
+    boxes resolve to sibling file pages."""
+    import xml.etree.ElementTree as _ET
+    html_root = xml_dir.parent / "html"
+    if not (html_root.is_dir() and _FILE_URL):
+        return
+    # Index both graph SVGs once (stem -> path) — avoids per-file globbing.
+    incl: dict[str, pathlib.Path] = {}
+    dep: dict[str, pathlib.Path] = {}
+    for svg in html_root.rglob("*__incl.svg"):
+        incl[svg.name[: -len("__incl.svg")]] = svg
+    for svg in html_root.rglob("*__dep.svg"):
+        dep[svg.name[: -len("__dep.svg")]] = svg
+    # Doxygen emitted no included-by graphs here, so reconstruct them ourselves.
+    _incby_rev, _incby_path = _included_by_map(xml_dir)
+    # Enums/functions by their declaring header — the file XML doesn't list them
+    # (Doxygen aggregates members by their ), so do the same here.
+    # Capture the full detail Doxygen shows on its file page: every enumerator
+    # (name + value) for enums, and the return type + signature for functions,
+    # plus each member's brief.
+    def _norm(el) -> str:
+        return " ".join(_itertext(el).split()) if el is not None else ""
+    member_index: dict[str, list[dict]] = {}
+    for gx in xml_dir.glob("group__*.xml"):
+        try:
+            groot = _ET.parse(str(gx)).getroot()
+        except _ET.ParseError:
+            continue
+        for md in groot.iter("memberdef"):
+            k = md.get("kind")
+            if k not in ("enum", "function"):
+                continue
+            loc = md.find("location")
+            f = loc.get("file", "") if loc is not None else ""
+            j = f.find("opencv2/")
+            if j < 0:
+                continue
+            name = (md.findtext("name") or "").strip()
+            qual = (md.findtext("qualifiedname") or "").strip() or name
+            entry = {"kind": k, "name": name, "qual": qual,
+                     "id": md.get("id", ""), "brief": _norm(md.find("briefdescription"))}
+            if k == "enum":
+                # Bare enumerator names (IMREAD_UNCHANGED), rendered inside a
+                # code-block box like the class page's "Public Types" enums.
+                entry["values"] = [
+                    ((ev.findtext("name") or "").strip(),
+                     _norm(ev.find("initializer")))
+                    for ev in md.findall("enumvalue")]
+            else:
+                entry["type"] = _norm(md.find("type"))
+                entry["args"] = (md.findtext("argsstring") or "").strip()
+                entry["static"] = md.get("static") == "yes"
+            member_index.setdefault(f[j:], []).append(entry)
+
+    def _own_or_parent_doc(refid: str) -> str | None:
+        """Docname of refid's own page, or — for a nested type with no standalone
+        page (e.g. `cv::ImageCollection::iterator`) — its enclosing class page."""
+        dn = _ANCHOR_TO_DOC.get(refid)
+        if dn:
+            return dn
+        body = refid
+        for pref in ("class", "struct", "union"):
+            if body.startswith(pref):
+                body = body[len(pref):]
+                break
+        while "_1_1" in body:
+            body = body.rsplit("_1_1", 1)[0]
+            for pref in ("class", "struct", "union"):
+                d = _ANCHOR_TO_DOC.get(pref + body)
+                if d:
+                    return d
+        return None
+
+    def _xref(refid: str, name: str) -> str:
+        """`[name](/docname.md)` (own or enclosing page), else plain text.
+
+        Plain link text (no code span) so names render as blue links like the
+        original Doxygen file page — not monospace chips."""
+        dn = _own_or_parent_doc(refid)
+        return f"[{name}](/{dn}.md)" if dn else name
+
+    def _ple(s: str) -> str:
+        """Backslash-escape markup chars for `parsed-literal` content, so a
+        signature's `<`/`>`/`*`/`_`/`[`/`]`/`` ` `` render literally while the
+        block still parses the one real link (the member name) we embed."""
+        for ch in "\\`*_<>[]":
+            s = s.replace(ch, "\\" + ch)
+        return s
+
+    def _decl_box(decl_lines: list) -> list:
+        """A `parsed-literal` box: looks like a code block (monospace, keeps
+        line breaks) but PARSES inline links — so the member name inside the box
+        is clickable, like the original Doxygen file page (no separate header)."""
+        return [":::{parsed-literal}", ":class: opencv-decl", ""] \
+            + decl_lines + [":::", ""]
+
+    for inc, rel in _FILE_URL.items():
+        stem = rel.rsplit("/", 1)[-1]
+        if stem.endswith(".html"):
+            stem = stem[:-5]                          # e.g. ios_8h
+        base = inc.rsplit("/", 1)[-1]                 # ios.h
+        # Mirror the Doxygen file page: #include directives, the include graph,
+        # the included-by graph, Classes and Namespaces (members are documented
+        # on their module/group pages and linked from there).
+        includes, classes, namespaces = [], [], []
+        fxml = xml_dir / f"{stem}.xml"
+        if fxml.is_file():
+            try:
+                cd = _ET.parse(str(fxml)).getroot().find("compounddef")
+            except _ET.ParseError:
+                cd = None
+            if cd is not None:
+                includes = [(_i.text or "").strip() for _i in cd.findall("includes")]
+                classes = [(_c.get("refid", ""), (_c.text or "").strip())
+                           for _c in cd.findall("innerclass")]
+                namespaces = [(_n.get("refid", ""), (_n.text or "").strip())
+                              for _n in cd.findall("innernamespace")]
+        # orphan: reached via #include links, not the nav toctree.
+        lines = ["---", "orphan: true", "---", "", f"# {inc}", ""]
+        if includes:
+            lines += ["```cpp"] + [f"#include <{i}>" for i in includes] + ["```", ""]
+        svg = incl.get(stem)
+        if svg is not None:
+            lines += _diagram_svg_lines(
+                svg, out_dir, f"Include dependency graph for {base}",
+                f"Include dependency graph for {base}:")
+        dsvg = dep.get(stem)
+        if dsvg is None:                          # Doxygen didn't make one — build it
+            dsvg = _generate_dep_graph_svg(stem, _incby_rev, _incby_path, out_dir)
+        if dsvg is not None:
+            _dep_lines = _diagram_svg_lines(
+                dsvg, out_dir, f"Files that include {base}",
+                f"This graph shows which files directly or indirectly "
+                f"include {base}:")
+            # Our generated raw SVG is intermediate (the hashed theme variants
+            # are what the page references); drop it. Never touch Doxygen's own.
+            if dsvg.parent == out_dir:
+                try:
+                    dsvg.unlink()
+                except OSError:
+                    pass
+            lines += _dep_lines
+        if classes:
+            lines += ["## Classes", ""]
+            for rid, nm in classes:
+                kind = ("struct" if rid.startswith("struct")
+                        else "union" if rid.startswith("union") else "class")
+                own = _ANCHOR_TO_DOC.get(rid)            # own page (for "More…")
+                brief = " ".join(_read_class_brief(rid, xml_dir).split())
+                # Clickable name inside the box; brief (+ More…) below it.
+                lines += _decl_box([f"{kind} {_xref(rid, nm)}"])
+                if brief:
+                    more = (f" [More…](/{own}.md#detailed-description)"
+                            if own else "")
+                    lines += [f"{brief}{more}", ""]
+            lines += [""]
+        if namespaces:
+            lines += ["## Namespaces", ""]
+            for rid, nm in namespaces:
+                # Namespace pages carry a `{#api_ns_}` heading label.
+                slug = nm.replace("::", "__")
+                lines += _decl_box([f"namespace [{nm}](#api_ns_{slug})"])
+            lines += [""]
+        # Enumerations / Functions declared in this header — each links (via its
+        # `(refid)=` MyST label) to its detail on the module/group page.
+        _members = member_index.get(inc, [])
+
+        def _uniq(kind: str) -> list[dict]:
+            seen, out = set(), []
+            for e in _members:
+                r = e.get("id")
+                if e["kind"] == kind and r and r not in seen:
+                    seen.add(r)
+                    out.append(e)
+            return out
+
+        def _name_link(e: dict) -> str:
+            return f"[{e['qual']}](#{e['id']})" if e.get("id") else e["qual"]
+        enums, funcs = _uniq("enum"), _uniq("function")
+        if enums:
+            lines += ["## Enumerations", ""]
+            for e in enums:
+                # One box per enum: the enum NAME and EVERY enumerator are
+                # clickable (all resolve to the enum's detail via its `(refid)=`
+                # label). Brief (+ More…) below — like the original file page.
+                def _ev(vn, vi):
+                    nm = (f"[{_ple(vn)}](#{e['id']})" if e.get("id")
+                          else _ple(vn))
+                    return f"    {nm}{(' ' + _ple(vi)) if vi else ''}"
+                _evs = [_ev(vn, vi) for vn, vi in e["values"]]
+                _evs = [v + ("," if i < len(_evs) - 1 else "")  # no trailing comma
+                        for i, v in enumerate(_evs)]
+                lines += _decl_box([f"enum {_name_link(e)} {{"] + _evs + ["}"])
+                if e["brief"]:
+                    lines += [f"{e['brief']} [More…](#{e['id']})"
+                              if e.get("id") else e["brief"], ""]
+            lines += [""]
+        if funcs:
+            lines += ["## Functions", ""]
+            for fn in funcs:
+                # Signature box with the function NAME clickable inside it
+                # (return type + args around it as literal text); brief below.
+                pre = "static " if fn.get("static") else ""
+                rty = f"{_ple(fn['type'])} " if fn["type"] else ""
+                decl = f"{pre}{rty}{_name_link(fn)} {_ple(fn['args'])}".rstrip()
+                lines += _decl_box([decl])
+                if fn["brief"]:
+                    lines += [fn["brief"], ""]
+            lines += [""]
+        if not (includes or svg or classes or namespaces or enums or funcs):
+            lines += [f"Defined in header `{inc}`.", ""]
+        _stub_write(out_dir / f"{stem}.md", "\n".join(lines))
+        _ANCHOR_TO_DOC[stem] = f"{out_dir.name}/{stem}"
+
+
 def _diagram_svg_lines(svg_path: pathlib.Path, out_dir: pathlib.Path,
                        alt: str, intro: str, extra_class: str = "") -> list[str]:
     """Write theme-aware variants of a Doxygen graph SVG; return its MyST block.
@@ -104,7 +587,8 @@ def _namespaces_section(entries: list) -> list[str]:
 def _write_namespace_stub(ns: dict, out_dir: pathlib.Path,
                           xml_dir: pathlib.Path,
                           ns_group_map: dict | None = None,
-                          group_info: dict | None = None) -> tuple[str, str]:
+                          group_info: dict | None = None,
+                          classes_seen: dict | None = None) -> tuple[str, str]:
     """Write namespace_.md under out_dir. Returns (anchor, fname)."""
     import xml.etree.ElementTree as _ET
     slug = ns["name"].replace("::", "__")
@@ -243,7 +727,24 @@ def _write_namespace_stub(ns: dict, out_dir: pathlib.Path,
         for ic_refid, ic_name, ic_kind, ic_brief in innerclasses:
             page = _class_page_name(ic_refid)
             short_name = ic_name[len(ns_prefix):]
-            lines.append(f"| [`{ic_kind} {short_name}`]({page}.md) |")
+            # Emit the `class`/`struct` keyword as a SEPARATE, non-
+            # clickable code chip — only the qualified short name is
+            # the link target. Mirrors the api-group Classes table
+            # post-rewrite (translate.py step 8e) so namespace-stub
+            # rows read the same way: `class` plain, name clickable.
+            lines.append(f"| `{ic_kind}` [`{short_name}`]({page}.md) |")
+            # Surface orphan namespace classes (not in any group) to the
+            # caller so their stubs get written — otherwise links like
+            # `class Node` 404. Group classes are still added by
+            # `_write_api_stub`; the dedupe keys off `refid`.
+            if classes_seen is not None and ic_refid not in classes_seen:
+                classes_seen[ic_refid] = {
+                    "refid":     ic_refid,
+                    "name":      ic_name,
+                    "qualified": ic_name,
+                    "kind":      ic_kind,
+                    "brief":     ic_brief,
+                }
         lines.append("")
 
     # Member summary tables.
@@ -254,22 +755,51 @@ def _write_namespace_stub(ns: dict, out_dir: pathlib.Path,
         lines.append(f"## {section_title}")
         lines.append("")
         if section_title == "Functions":
-            lines += ["{.api-function-table}", "| Return | Name |", "|---|---|"]
+            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 - # Multi-line, one-param-per-line signature (matching the detail - # block); return type stays in its own cell, so head = name. - label = _func_sig_md(m["name"], m.get("params_sig")) - lines.append(f"| {ret_md} | [{label}](#{m['id']}) |") + lines.append(f"| {ret_md} | {_ns_func_row(m)} |") elif section_title in ("Typedefs", "Variables"): for m in items: lines.append("```cpp") - lines.append(f"typedef {m['type']} {m['name']}" if section_title == "Typedefs" - else f"{m['type']} {m['name']}") + if section_title == "Typedefs": + # Append so function-pointer typedefs (the name + # lives inside the "void(*" type) don't truncate; empty for + # plain typedefs. + _args = (m.get("args") or "").strip() + lines.append(f"typedef {m['type']} {m['name']}{_args}") + else: + lines.append(f"{m['type']} {m['name']}") lines.append("```") lines.append("") continue @@ -353,6 +883,96 @@ def _write_namespace_stub(ns: dict, out_dir: pathlib.Path, return anchor, fname +def _read_gapi_full_content() -> str: + """Read and convert full content from gapi 00-root.markdown for gapi_ref stub.""" + import re as _re + candidates = [ + CONTRIB_ROOT / "gapi" / "doc" / "00-root.markdown", + CONTRIB_ROOT / "modules" / "gapi" / "doc" / "00-root.markdown", + ] + root_md = next((p for p in candidates if p.is_file()), None) + if root_md is None: + return "" + + # Find sample file for API Example + sample_candidates = [ + CONTRIB_ROOT / "gapi" / "samples" / "api_example.cpp", + CONTRIB_ROOT / "modules" / "gapi" / "samples" / "api_example.cpp", + ] + sample_file = next((p for p in sample_candidates if p.is_file()), None) + + text = root_md.read_text(encoding="utf-8", errors="ignore") + + # Strip top-level title — gapi_ref stub has its own title + text = _re.sub(r"^# Graph API[^\n]*\n+", "", text) + + # Downgrade # headings to ## (since gapi_ref stub already has a # title) + text = _re.sub(r"^# ([^\n]+)", r"## \1", text, flags=_re.MULTILINE) + + # Strip Doxygen heading anchors like {#gapi_root_intro} + text = _re.sub(r"\s*\{#\w+\}", "", text) + + # Convert @note to MyST admonition (three colons required) + def _note(m): + body = m.group(1).strip() + return f"\n:::{{note}}\n{body}\n:::\n" + text = _re.sub(r"@note\s+(.*?)(?=\n##|\n- |\Z)", _note, text, flags=_re.DOTALL) + + # Chapter subpages: link to Doxygen HTML pages in contrib_modules + _chapter_pages = { + "gapi_purposes": ("Why Graph API?", "../contrib_modules/gapi/doc/01-background.html"), + "gapi_hld": ("High-level design overview", "../contrib_modules/gapi/doc/10-hld-overview.html"), + "gapi_kernel_api": ("Kernel API", "../contrib_modules/gapi/doc/20-kernel-api.html"), + "gapi_impl": ("Implementation details", "../contrib_modules/gapi/doc/30-implementation.html"), + } + + # API group subpages → links to extra_modules pages + _api_groups = { + "gapi_ref": ("G-API framework", "gapi_ref"), + "gapi_core": ("G-API Core functionality", "gapi_core"), + "gapi_imgproc": ("G-API Image processing functionality", "gapi_imgproc"), + "gapi_video": ("G-API Video processing functionality", "gapi_video"), + "gapi_draw": ("G-API Drawing and composition functionality", "gapi_draw"), + } + + def _subpage(m): + name = m.group(1) + if name in _chapter_pages: + label, url = _chapter_pages[name] + return f"[{label}]({url})" + if name in _api_groups: + label, page = _api_groups[name] + return f"[{label}](../extra_modules/{page}.md)" + return name + text = _re.sub(r"@subpage\s+(\w+)", _subpage, text) + + # Convert @ref tutorial_table_of_content_gapi → link to gapi tutorial page + text = _re.sub( + r"@ref\s+tutorial_table_of_content_gapi", + "[tutorials and porting examples](../tutorials_contrib/gapi/gapi.md)", + text) + + # Replace @include with fenced code block + def _include(m): + rel = m.group(1).strip() + if sample_file and sample_file.is_file(): + code = sample_file.read_text(encoding="utf-8", errors="ignore") + return f"\n```cpp\n{code.rstrip()}\n```\n" + return f"\n`{rel}`\n" + text = _re.sub(r"@include\s+(\S+)", _include, text) + + # Rewrite relative image paths to point to contrib_modules location + text = _re.sub( + r"!\[([^\]]*)\]\(pics/([^)]+)\)", + r"![\1](../contrib_modules/gapi/doc/pics/\2)", + text) + + # Strip HTML comments + text = _re.sub(r"", "", text, flags=_re.DOTALL) + + return text.strip() + + def _write_api_stub(node: dict, out_dir: pathlib.Path, classes_seen: dict, ns_map: dict | None = None) -> None: """Write one .md per group node, recursing into children. @@ -365,6 +985,22 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, lines = [f"# {title} {{#api_{name}}}", ""] + # Fully hand-rendered fallback page (module with no Doxygen XML): emit the + # prepared body verbatim, then write its topic/class subpages (the body's + # links + hidden toctree point at them). No class/subgroup XML to parse. + if node.get("body_md"): + lines += [node["body_md"], ""] + _stub_write(out, "\n".join(lines) + "\n") + for _cn, _md in node.get("child_pages", []): + _stub_write(out_dir / f"{_cn}.md", _md + "\n") + return + + # Brief + "View details" under the title (mirrors the Doxygen group page). + _brief = node.get("brief") or "" + if _brief: + _more = " [View details](#detailed-description)" if node["detailed"] else "" + lines += [f"{_brief}{_more}", ""] + if node["children"]: lines += ["## Topics", ""] for child in node["children"]: @@ -373,7 +1009,8 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, # Detailed Description heading — shown even when empty (ayush's layout). if node["detailed"]: - lines += ["## Detailed Description", "", node["detailed"], ""] + _dd = f"{_brief}\n\n{node['detailed']}" if _brief else node["detailed"] + lines += ["## Detailed Description", "", _dd, ""] elif node["innerclasses"] or node["sections"] or node["children"]: lines += ["## Detailed Description", ""] if ns_map and ns_map.get(name): @@ -415,7 +1052,93 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, return f"[{text}]({_class_page_name(c['refid'])}.md)" return f"[{text}](#{m['id']})" - _rich_return = (name == "core_basic") + def _member_anchor_target(m: dict) -> str: + """URL (page or in-page anchor) that the function name in a row + should link to. Same resolution as `_member_anchor_link`, but + returns the bare target string instead of a wrapped markdown + link — so the caller can put the link ONLY around the function + name and leave the parameter types/names as separate spans. + + On Core-functionality pages where `_render_core_basic_func` + emits the function detail block, the anchor on the page is + `#cv-` (from `_func_slug(name)`), NOT the + slug-normalized refid `#group-core-…-1ga`. Without this + branch the summary-table row links to a `group-…` anchor that + doesn't exist on the page → click goes nowhere. Functions on + non-core pages still fall back to the refid-slug form (which + matches the MyST `({refid})=` target Sphinx emits when no + explicit anchor is used).""" + if _is_class_member(m): + q = m["qualified"] + 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" + # 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"): + return f"#{_func_slug(m['name'])}" + import re as _re + return f"#{_re.sub(r'_+', '-', m['id'])}" + + def _func_row_split_md(m: dict) -> str: + """Function summary-row Name cell as ONE continuous inline-code + block (no separate chips). The function name (with `cv::` + prefix) is wrapped in an `` inside the `` and points + at the detail anchor. Parameter types are still individually + clickable — step 8g's pass 1 walks the inner HTML of this + ``, skips the embedded ``, and linkifies any + recognized type tokens it finds in the rest of the signature. + Parameter names + default values stay plain (no link).""" + from html import escape as _esc_html + target = _member_anchor_target(m) + # `::` is HTML-entity-encoded so the later `_linkify_cv_symbols` + # text-level pass doesn't see `cv::Name` and nest a second + # anchor inside ours. Browsers decode back to `:` on render. + name_text = f"cv::{m['name']}".replace("::", "::") + name_html = (f'{name_text}') + params_sig = m.get("params_sig") or [] + # Pipe escaping: a literal `|` inside the cell would split the + # markdown-table row, so swap to its HTML entity. + def _esc(s: str) -> str: + return _esc_html(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: + # Multi-line: `
` inside the code block gives one param + # per line; CSS already handles `` line breaks for + # the existing detail blocks. + last_i = len(params_sig) - 1 + lines = [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 "") + lines.append(f" {_esc(t)} {_esc(decl)}{tail}") + inner = "
".join(lines) + return f'{inner}' + + # Renders the summary table (or enum synopsis) for one member kind given a + # list of members — used both for the standard per-kind sections and for + # the @name-group sections appended afterwards. Returns markdown lines + # (no `## heading`); enum output already carries its own trailing blanks. + # + # `_is_core_page` extends the original `core_basic`-only treatment to + # every Core-functionality group page (core_array, core_cluster, + # core_utils, …). The user's spec: "apply the same clickability + + # redirection rules to all other pages in core functionality" — that + # means the clickable HTML enum synopsis, the "More..." link from + # summary to detail, the per-page hand-rolled function detail blocks, + # the rich return-type cell, and the per-enum detail section all flip + # on together for every `core_*` group. Any page whose group name + # starts with `core` qualifies (top-level group is "core"; children + # are "core_basic", "core_array", …). + _is_core_page = name.startswith("core") + _rich_return = _is_core_page def _summary_block(section_title: str, members: list) -> list[str]: out: list[str] = [] @@ -424,8 +1147,7 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, "| Return | Name | Description |", "|---|---|---|"] for m in members: ret_type = _md_escape_cell(m["type"]) - label = _func_sig_md(m["name"], m.get("params_sig")) - sig_link = _member_anchor_link(m, label, raw=True) + sig_link = _func_row_split_md(m) if not ret_type: ret = "\u00a0" # ctor/dtor: blank cell, never backticked elif _rich_return: @@ -444,7 +1166,22 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, for m in members: t = _md_escape_cell(m["type"]) t_cell = f"`{t}`" if t else "\u00a0" - name_link = _member_anchor_link(m, f"cv::{m['name']}") + # Function-pointer typedefs (e.g. cv::dnn::ActivationFunc) store + # the name inside the type ("void(*") with the params in + # (")(const void *...)"). Split the type: keep only + # the return type ("void") in the Type column and move the + # "(*)(args)" declarator into the Name column, so the + # row reads naturally as "void | (*cv::dnn::ActivationFunc)(...)" + # rather than the disjoint "void(*" / "cv::ActivationFunc)(...)". + # Plain typedefs have no argsstring, so they keep "cv::". + fp_args = (m.get("args") or "").strip() + if fp_args and "(" in (m.get("type") or ""): + ret, _sep, rest = (m.get("type") or "").strip().partition("(") + t_cell = f"`{_md_escape_cell(ret.strip())}`" if ret.strip() else "\u00a0" + label = "(" + rest + (m.get("qualified") or f"cv::{m['name']}") + fp_args + else: + label = f"cv::{m['name']}" + name_link = _member_anchor_link(m, label.replace("|", "\\|")) out.append(f"| {t_cell} | {name_link} | {_md_escape_cell(m['brief'])} |") elif section_title == "Variables": out += ["{.api-reference-table}", @@ -455,56 +1192,93 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, name_link = _member_anchor_link(m, m["name"]) out.append(f"| {t_cell} | {name_link} | {_md_escape_cell(m['brief'])} |") elif section_title == "Enumerations": + # Code-style synopsis (Doxygen layout) instead of name/desc table. + # On non-core group pages we emit the synopsis only — the + # per-value initializer list is already self-explanatory. On + # every Core-functionality page we additionally append a + # "More..." link, inline at the end of the brief description + # (or alone below the synopsis when no brief exists), + # pointing to that enum's detail block in the "Enumeration + # Type Documentation" section emitted by the detail loop + # below. + _enum_more_link = True + # On every Core-functionality page the synopsis is emitted + # as raw HTML (NOT a ```cpp code fence) so every `cv::…` + # token becomes its own `` linking to the enum's detail + # block. We hand-roll Pygments-style spans (`k`, `n`, `p`) + # so the existing `.highlight pre` styling kicks in and the + # synopsis still looks like a code block. + _clickable_synopsis = True import html as _html_mod # Encode `::` so translate's cv-linkifier skips it. def _safe(s: str) -> str: return _html_mod.escape(s).replace("::", "::") for m in members: - _anchor = (m.get("name") or "").lower() or m["id"] - _href = f"#{_anchor}" - _qual = m["qualified"] or m["name"] - _is_strong = bool(m.get("strong")) - _keyword = "enum struct" if _is_strong else "enum" - # Enumerator name prefix (scope). - if _is_strong: - _val_prefix = _qual + "::" - elif "::" in _qual: - _val_prefix = _qual.rsplit("::", 1)[0] + "::" - else: - _val_prefix = "" - out.append( - '
'
-                )
-                # Anonymous enums have no name to link; emit a bare `enum {`.
-                _name_html = (
-                    f''
-                    f'{_safe(_qual)} ' if _qual else "")
-                out.append(
-                    f'{_html_mod.escape(_keyword)} '
-                    f'{_name_html}{{'
-                )
-                _vals = m.get("enum_values") or []
-                for _i, _v in enumerate(_vals):
-                    _comma = (','
-                              if _i < len(_vals) - 1 else '')
-                    _init = (' ' + _html_mod.escape(_v["initializer"])
-                             if _v.get("initializer") else '')
-                    _full = _val_prefix + _v["name"]
+                _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()})"
+                if _clickable_synopsis:
+                    _qual = m["qualified"] or m["name"]
+                    _is_strong = bool(m.get("strong"))
+                    _keyword = "enum struct" if _is_strong else "enum"
+                    # Enumerator-name prefix: scoped → "cv::EnumName::",
+                    # unscoped → the enum's parent scope (so values
+                    # render as `cv::ACCESS_READ`).
+                    if _is_strong:
+                        _val_prefix = _qual + "::"
+                    elif "::" in _qual:
+                        _val_prefix = _qual.rsplit("::", 1)[0] + "::"
+                    else:
+                        _val_prefix = ""
+                    _href = f"#{m['name'].lower()}"  # enum detail block id
                     out.append(
-                        f'    '
-                        f'{_safe(_full)}'
-                        f'{_init}{_comma}'
+                        '
'
                     )
-                out.append('}
') - # Blank line closes the raw-HTML block (CommonMark rule 7). - out.append("") - _details = (f'View details') - if m["brief"]: - out.append(f'{_md_escape_cell(m["brief"])} {_details}') + # Anonymous enums have no name to link; emit a bare `enum {`. + _name_html = ( + f'' + f'{_safe(_qual)} ' if _qual else "") + out.append( + f'{_html_mod.escape(_keyword)} ' + f'{_name_html}{{' + ) + _vals = m.get("enum_values") or [] + for _i, _v in enumerate(_vals): + _comma = (',' + if _i < len(_vals) - 1 else '') + _init = (' ' + _html_mod.escape(_v["initializer"]) + if _v.get("initializer") else '') + _full = _val_prefix + _v["name"] + # All enumerators link to the enum's detail section via + # its `#` heading anchor (which exists on the + # page). Per-value `_CPPv4…` ids are NOT emitted in the + # detail block, so a per-value href would dead-link. + out.append( + f' ' + f'{_safe(_full)}' + f'{_init}{_comma}' + ) + out.append('}
') + # Blank line closes the raw-HTML block (CommonMark rule 7). + out.append("") else: - out.append(_details) + out.append("```cpp") + out.extend(_enum_synopsis_lines(m)) + out.append("```") + # Brief + the inline "More..." link (when generated). + if m["brief"]: + line = _md_escape_cell(m["brief"]) + if _more: + line = f"{line} {_more}" + _more = "" + out.append(line) + if _more: + out.append(_more) out.append("") else: # Macros out += ["{.api-reference-table}", "| Name | Description |", "|---|---|"] @@ -534,13 +1308,13 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, lines.append("") # Detail blocks via `_render_member_detail` (breathe chokes); macros keep - # `{doxygendefine}`; enum detail is hand-rolled (core_basic only). + # `{doxygendefine}`; enum detail is hand-rolled (every core_* page). seen_define_names: set[str] = set() for kind_key, section_title in _MEMBERDEF_SECTIONS: items = node["sections"].get(section_title, []) if not items: continue - _core_basic_funcs = (name == "core_basic" and kind_key == "function") + _core_basic_funcs = (_is_core_page and kind_key == "function") _ov_total: dict[str, int] = {} _ov_idx: dict[str, int] = {} _slug_seen: set[str] = set() @@ -567,7 +1341,12 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, m, _ov_idx[short], _ov_total.get(short, 1), emit_anchor)) continue if kind_key == "enum": - # Hand-rolled (breathe's {doxygenenum} drops initializers/briefs). + # Every core_* page (gated by the early `continue` above). + # Hand-rolled in place of `{doxygenenum}` — breathe's directive + # drops every enumerator's initializer and `briefdescription` + # (renders the `
` empty), so the live page's per-value + # `=1<<24` constants and one-line descriptions vanished. + # Pulling from the XML metadata ourselves restores them. _qual = m["qualified"] or m["name"] _is_strong = bool(m.get("strong")) _keyword = "enum class" if _is_strong else "enum" @@ -587,14 +1366,14 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, blk = [f'

{_keyword}

', ""] if m.get("include_file"): _einc = m["include_file"] - _eifile = _FILE_URL.get(_einc) - if _eifile: + _ehref = _include_page_href(_einc) + if _ehref: blk += [ "{.opencv-api-include}", f'' f'#include <' + f'href="{_ehref}">' f'{_einc}>', "", ] @@ -616,8 +1395,13 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, if m["name"] in seen_define_names: continue seen_define_names.add(m["name"]) + # Hand-rolled block (breathe's {doxygendefine} drops the #include and + # the macro's Value); `_render_member_detail` keeps `#define NAME(…)`, + # the include row and the Value, and emits the `(id)=` cross-ref anchor. + m = _enhance_xphoto_member(m) + _full_name = m["qualified"] or m["name"] blocks.append( - _render_member_detail(m, m["qualified"] or m["name"])) + _render_member_detail(m, _full_name)) if not blocks: continue lines.append(f"## {_MEMBER_DETAIL_SECTION[section_title]}") @@ -638,6 +1422,56 @@ def _write_api_stub(node: dict, out_dir: pathlib.Path, _write_api_stub(child, out_dir, classes_seen, ns_map) +def _collect_base_chain(data: dict, xml_dir: pathlib.Path) -> list: + """Transitive base classes in inheritance order, each with its loaded class + data — for rendering Doxygen's "inherited from " member sections. + Deduped and cycle-safe.""" + out: list = [] + seen: set = set() + queue = list(data.get("bases", [])) + while queue: + refid, name, _prot = queue.pop(0) + if not refid or refid in seen: + continue + seen.add(refid) + bdata = _read_class_data(refid, xml_dir) + if bdata is None: + continue + out.append((refid, bdata.get("name") or name, bdata)) + queue.extend(bdata.get("bases", [])) + return out + + +def _inherited_section(summary_title: str, base_qual: str, base_refid: str, + items: list) -> list[str]: + """A collapsible " inherited from <base>" block (sphinx-design + dropdown). The base name and every member name are clickable links; the + return-type column stays plain — only the links are blue.""" + # Absolute (root-relative) docname so the base link resolves even when the + # base class lives in a different module dir (e.g. cv::Algorithm in + # main_modules linked from an extra_modules page). + _dn = _ANCHOR_TO_DOC.get(base_refid) + base_link = f"[{base_qual}](/{_dn}.md)" if _dn else base_qual + out = [f":::{{dropdown}} {summary_title} inherited from {base_link}", + ":animate: fade-in", "", + "{.api-reference-table .api-function-table}", + "| Return | Name | Description |", "|---|---|---|"] + for m in items: + ret = _md_escape_cell(m["type"]) + if ret and m.get("static"): + ret = "static " + ret + ret_cell = f"`{ret}`" if ret else " " + if m["kind"] == "function": + label = _func_sig_md(m["name"], m.get("params_sig")) + sig_link = f"[{label}](#{m['id']})" + else: + sig = f"{m['name']}{_md_escape_cell(m['args'])}" + sig_link = f"[`{sig}`](#{m['id']})" + out.append(f"| {ret_cell} | {sig_link} | {_md_escape_cell(m['brief'])} |") + out += ["", ":::", ""] + return out + + # sectiondef kind → summary heading, in Doxygen order. _CLASS_SUMMARY_SECTIONS = [ ("public-type", "Public Types"), @@ -658,11 +1492,18 @@ def _param_item_lines(nm: str, desc: str) -> list[str]: """Render one `**Parameters**` entry, indenting any multi-line / bulleted description so it nests under the param bullet. Without this a description carrying its own list (e.g. calibration `flags`) collapses into a run-on - blob or breaks out past the card boundary as a flat sibling list.""" + blob or breaks out past the card boundary as a flat sibling list. + + Param NAME renders as a monospace box (`code.opencv-param-name`, styled + for light + dark in custom.css).""" + import html as _html + chip = f'<code class="opencv-param-name">{_html.escape(nm)}</code>' if not desc: - return [f"- `{nm}`"] + return [f"- {chip}"] lines = desc.split("\n") - out = [f"- `{nm}` — {lines[0]}"] + out = [f"- {chip} — {lines[0]}"] + # Continuation lines align with the bullet's content column (2 spaces); + # blank lines stay empty so the nested list/paragraphs render loosely. out += [f" {ln}" if ln.strip() else "" for ln in lines[1:]] return out @@ -678,7 +1519,13 @@ def _enumerator_list_table(values: list[dict], enum_qualified: str, if not values: return [] has_desc = any((v.get("brief") or "").strip() for v in values) - out = ["```{list-table}", ":header-rows: 0", + # Lead with a blank line: the class-member enum path emits a raw `<h3>` (and + # optional `<p>` brief) immediately before this table. Without the separator + # CommonMark folds the `​```{list-table}` opener and its `:option:` lines into + # that HTML block (HTML blocks run until a blank line), leaving the closing + # ``` orphaned — it then opens a stray code block that swallows the rest of + # the page (Member Function docs, etc.) as literal text. + out = ["", "```{list-table}", ":header-rows: 0", f":widths: {'30 70' if has_desc else '100'}", ":class: opencv-enum-table", ""] for v in values: @@ -747,6 +1594,8 @@ def _render_member_detail(m: dict, full_name: str) -> list[str]: kind = m["kind"] # Heading is just `name()` for functions; full signature is in the block below. head = f"{short}()" if kind == "function" else short + if m.get("id"): + _LOCAL_MEMBER_IDS.add(m["id"]) out = [f"({m['id']})=", f"### {head}".rstrip(), ""] # Declaration (template line, if any, then the C++ signature). @@ -755,29 +1604,57 @@ def _render_member_detail(m: dict, full_name: str) -> list[str]: typ = (m.get("type") or "").strip() if kind == "function": # One parameter per line, type column padded so names align. - head = f"{prefix}{typ + ' ' if typ else ''}{full_name}" + # sig_prefix/sig_suffix allow callers to inject qualifiers (e.g. virtual/const = 0). + sig_prefix = m.get("sig_prefix") or "" + sig_suffix = m.get("sig_suffix") or "" + head = f"{sig_prefix}{prefix}{typ + ' ' if typ else ''}{full_name}" sig_lines = _signature_lines(head, m.get("params_sig") or []) + if sig_suffix: + sig_lines[-1] = sig_lines[-1] + sig_suffix elif kind == "define": # `#define NAME(args)` — macro params carry only a name, no type. mp = m.get("macro_params") or [] params = f"({', '.join(mp)})" if mp else "" sig_lines = [f"#define {short}{params}"] elif kind == "typedef": - sig_lines = [f"typedef {typ} {full_name}".strip()] + # Function-pointer typedefs (e.g. cv::dnn::ActivationFunc) are split by + # Doxygen across <type>/<name>/<argsstring>: type="void(*", the name sits + # in the middle, and argsstring=")(const void *input, …)". Append the + # args or the decl truncates to "typedef void(* cv::dnn::ActivationFunc". + # Plain typedefs carry an empty argsstring, so this is a no-op for them. + args = (m.get("args") or "").strip() + sig_lines = [f"typedef {typ} {full_name}{args}".strip()] else: # variable / attribute — append the `= value` initializer if present. decl = f"{prefix}{typ + ' ' if typ else ''}{full_name}".strip() init = (m.get("initializer") or "").strip() sig_lines = [f"{decl} {init}".strip() if init else decl] # Template clause + declaration as inline code (keeps token-linkifier # active); `{.opencv-api-sig}` lets the CSS preserve the alignment spaces. - _sig = ([f"`{tmpl}`"] if tmpl else []) + [f"`{ln}`" for ln in sig_lines] + # Lines carrying a `<ref>` (a type/typedef/enum value Doxygen hyperlinks) + # are emitted as an HTML <code> with explicit, refid-resolved links — same + # element as a markdown code span, so the box format is unchanged. + _url_by_text: dict = {} + for _t, _rid in (m.get("sig_refs") or []): + if _t and _t not in _url_by_text: + _u = _rel_doc_url(_rid) + if _u: + _url_by_text[_t] = _u + + import html as _html_pkg + def _code(ln: str) -> str: + if _url_by_text: + _h = _sig_html_with_links(ln, _url_by_text) + if _h is not None: + return f'<code class="docutils literal notranslate">{_h}</code>' + return (f'<code class="docutils literal notranslate">' + f'{_html_pkg.escape(ln)}</code>') + _sig = ([f"`{tmpl}`"] if tmpl else []) + [_code(ln) for ln in sig_lines] out += ["{.opencv-api-sig}", "\\\n".join(_sig), ""] inc = (m.get("include_file") or "").strip() if inc: - _ifile = _FILE_URL.get(inc) - if _ifile: - _href = f"../../../doc/doxygen/html/{_ifile}" + _href = _include_page_href(inc) + if _href: out += [ "{.opencv-api-include}", f'<code class="docutils literal notranslate">' @@ -844,14 +1721,28 @@ def _render_core_basic_func(m: dict, idx: int, total: int, qname = m["qualified"] or m["name"] head = f"{storage}{ret} {qname}".strip() sig_lines = _signature_lines(head, m.get("params_sig") or []) + _url_by_text: dict = {} + for _t, _rid in (m.get("sig_refs") or []): + if _t and _t not in _url_by_text: + _u = _rel_doc_url(_rid) + if _u: + _url_by_text[_t] = _u + + import html as _html_pkg + def _code(ln: str) -> str: + if _url_by_text: + _h = _sig_html_with_links(ln, _url_by_text) + if _h is not None: + return f'<code class="docutils literal notranslate">{_h}</code>' + return (f'<code class="docutils literal notranslate">' + f'{_html_pkg.escape(ln)}</code>') _sig = ([f"`{m['template']}`"] if m.get("template") else []) + \ - [f"`{ln}`" for ln in sig_lines] + [_code(ln) for ln in sig_lines] out += ["{.opencv-api-sig}", "\\\n".join(_sig), ""] if m.get("include_file"): _ipath = m["include_file"] - _ifile = _FILE_URL.get(_ipath) - if _ifile: - _href = f"../../../doc/doxygen/html/{_ifile}" + _href = _include_page_href(_ipath) + if _href: out += [ "{.opencv-api-include}", f'<code class="docutils literal notranslate">' @@ -907,12 +1798,12 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, lines.append("") _inc = (_header_data.get("include") or "").strip() if _inc: - _cifile = _FILE_URL.get(_inc) - if _cifile: + _chref = _include_page_href(_inc) + if _chref: _inc_code = ( f'#include <<a class="reference external ' f'opencv-include-link" ' - f'href="../../../doc/doxygen/html/{_cifile}">' + f'href="{_chref}">' f'{_html_pkg.escape(_inc)}</a>>') else: _inc_code = f'#include <{_html_pkg.escape(_inc)}>' @@ -943,10 +1834,25 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, _stub_write(out, "\n".join(lines)) return - # 1) Summary tables in Doxygen's order. + # 1) Summary tables in Doxygen's order, each followed by the same-kind + # members inherited from base classes (collapsible, like Doxygen). + base_chain = _collect_base_chain(data, xml_dir) + _additional: list = [] # protected/private inherited -> trailing group for sd_kind, summary_title in _CLASS_SUMMARY_SECTIONS: items = data["sections"].get(sd_kind, []) - if not items: + inherited = [(rid, qual, bdata["sections"].get(sd_kind, [])) + for rid, qual, bdata in base_chain + if bdata["sections"].get(sd_kind, [])] + # Public inherited members sit inline under their section; inherited + # protected/private members go in a trailing "Additional Inherited + # Members" group, matching Doxygen's layout. + if sd_kind.startswith("public"): + inline_inherited = inherited + else: + inline_inherited = [] + _additional += [(summary_title, rid, qual, bi) + for rid, qual, bi in inherited] + if not items and not inline_inherited: continue lines.append(f"## {summary_title}") lines.append("") @@ -975,6 +1881,15 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, # HTML synopsis: `<a>` ids match the `_CPPv4…` detail anchors. lines.extend(_enum_synopsis_html(m, strip_scope=qualified)) lines.append("") + # Public members of this kind inherited from each base class (inline). + for _b_refid, _b_qual, _b_items in inline_inherited: + lines += _inherited_section(summary_title, _b_qual, _b_refid, _b_items) + + # Inherited protected/private members, grouped like Doxygen. + if _additional: + lines += ["## Additional Inherited Members", ""] + for _title, _b_refid, _b_qual, _b_items in _additional: + lines += _inherited_section(_title, _b_qual, _b_refid, _b_items) _directive = "doxygenstruct" if cls["kind"] == "struct" else "doxygenclass" examples = _find_examples_for_class(qualified.rsplit("::", 1)[-1]) @@ -988,6 +1903,12 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, "", ] lines += _render_examples_block(examples) + elif (data.get("brief") or "").strip(): + # No separate detailed text — show the brief under a Detailed + # Description heading so the section (and its `#detailed-description` + # anchor, which "More…" links target) exists, like the original docs. + lines += ["## Detailed Description", "", data["brief"].strip(), ""] + lines += _render_examples_block(examples) elif examples: lines += ["## Examples", ""] lines += _render_examples_block(examples) @@ -1056,6 +1977,8 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, if func_items: lines += ["## Member Function Documentation", ""] for m in _dedupe(func_items): + # Enhance xphoto documentation with detailed descriptions + m = _enhance_xphoto_member(m, qualified) lines += _render_member_detail(m, f"{qualified}::{m['name']}") if var_items: @@ -1069,11 +1992,11 @@ def _write_class_stub(cls: dict, out_dir: pathlib.Path, _kind_word = "struct" if cls["kind"] == "struct" else "class" _dir = _src_inc.rsplit("/", 1)[0] + "/" if "/" in _src_inc else "" _base = _src_inc.rsplit("/", 1)[-1] - _ifile = _FILE_URL.get(_src_inc) - if _ifile: + _fhref = _include_page_href(_src_inc) + if _fhref: _flink = (f'{_html_pkg2.escape(_dir)}<a class="reference external ' f'opencv-include-link" ' - f'href="../../../doc/doxygen/html/{_ifile}">' + f'href="{_fhref}">' f'{_html_pkg2.escape(_base)}</a>') else: _flink = _html_pkg2.escape(_src_inc) @@ -1116,18 +2039,330 @@ def _write_placeholder_stubs(out_dir: pathlib.Path, _ANCHOR_TO_DOC[stem] = f"{out_dir.name}/{stem}" +# Feature-complete content for optional modules whose Doxygen group XML is +# absent from this build (disabled / hardware-gated — e.g. cannops needs the +# Ascend SDK). When the XML is missing we render a full page from this data — +# Topics, Classes, Functions and Function Documentation — mirroring the official +# group pages. Links are either working `#include` file pages or in-page anchors +# (no dependency on class/subgroup pages that don't exist here → no 404s). +# Each module: title, include (umbrella, top of page), fn_include (shown in +# Function Documentation), description (markdown), topics, classes +# (kind, qualified, brief), functions (return, qualified, args, brief). +_FALLBACK_MODULE_DATA: dict = { + "datasets": { + "title": "Framework for working with different datasets", + "include": "opencv2/datasets/dataset.hpp", + "fn_include": "opencv2/datasets/util.hpp", + "description": + "The datasets module includes classes for working with different " + "datasets: load data, evaluate different algorithms on them, " + "contains benchmarks, etc.\n\n" + "It is planned to have:\n\n" + "- *basic*: loading code for all datasets to help start work with " + "them.\n" + "- *next stage*: quick benchmarks for all datasets to show how to " + "solve them using OpenCV and implement evaluation code.\n" + "- *finally*: implement on OpenCV state-of-the-art algorithms, " + "which solve these tasks.", + "topics": ["Action Recognition", "Face Recognition", + "Gesture Recognition", "Human Pose Estimation", + "Image Registration", "Image Segmentation", + "Multiview Stereo Matching", "Object Recognition", + "Pedestrian Detection", "SLAM", "Super Resolution", + "Text Recognition", "Tracking"], + "classes": [ + ("class", "cv::datasets::Dataset", ""), + ("struct", "cv::datasets::Object", ""), + ], + "functions": [ + ("void", "cv::datasets::createDirectory", + "(const std::string &path)", + "Create a directory at the given path."), + ("void", "cv::datasets::getDirList", + "(const std::string &dirName, " + "std::vector< std::string > &fileNames)", + "List the file names in a directory."), + ("void", "cv::datasets::split", + "(const std::string &s, std::vector< std::string > &elems, " + "char delim)", + "Split a string into tokens on a delimiter."), + ], + }, + "dnn_objdetect": { + "title": "DNN used for object detection", + "include": "opencv2/core_detect.hpp", + "fn_include": "opencv2/core_detect.hpp", + "description": + "The dnn_objdetect module includes deep-neural-network utilities " + "for object detection, grouping the structures and bounding-box " + "handling required to run and post-process specialized object " + "localization models on top of the OpenCV DNN backend.", + "classes": [ + ("class", "cv::dnn_objdetect::InferBbox", + "A class to post process model predictions."), + ("struct", "cv::dnn_objdetect::object", + "Structure to hold the details pertaining to a single bounding " + "box."), + ], + "functions": [], + }, + "quality": { + "title": "Image Quality Analysis (IQA) API", + "include": "opencv2/quality.hpp", + "fn_include": "opencv2/quality/qualitybase.hpp", + "description": + "The quality module implements Image Quality Analysis (IQA) " + "metrics. It provides algorithms to compute objective image-quality " + "scores — full-reference metrics against a reference image, plus " + "the no-reference BRISQUE metric — behind a common QualityBase " + "interface.", + "classes": [ + ("class", "cv::quality::QualityBase", ""), + ("class", "cv::quality::QualityBRISQUE", + "BRISQUE (Blind/Referenceless Image Spatial Quality Evaluator) is " + "a No Reference Image Quality Assessment (NR-IQA) algorithm."), + ("class", "cv::quality::QualityGMSD", + "Full reference GMSD algorithm"), + ("class", "cv::quality::QualityMSE", + "Full reference mean square error algorithm " + "https://en.wikipedia.org/wiki/Mean_squared_error"), + ("class", "cv::quality::QualityPSNR", + "Full reference peak signal to noise ratio (PSNR) algorithm " + "https://en.wikipedia.org/wiki/Peak_signal-to-noise_ratio"), + ("class", "cv::quality::QualitySSIM", + "Full reference structural similarity algorithm " + "https://en.wikipedia.org/wiki/Structural_similarity"), + ], + "functions": [], + }, + "reg": { + "title": "Image Registration", + "include": "opencv2/reg/map.hpp", + "fn_include": "opencv2/reg/mapper.hpp", + "description": ( + "The Registration module implements parametric image registration. " + "The implemented method is direct alignment, that is, it uses " + "directly the pixel values for calculating the registration between " + "a pair of images, as opposed to feature-based registration.\n\n" + "Feature based methods have some advantages over pixel based " + "methods when we are trying to register pictures that have been " + "shoot under different lighting conditions or exposition times, or " + "when the images overlap only partially. On the other hand, the " + "main advantage of pixel-based methods when compared to feature " + "based methods is their better precision for some pictures (those " + "shoot under similar lighting conditions and that have a " + "significative overlap), due to the fact that we are using all the " + "information available in the image, which allows us to achieve " + "subpixel accuracy. This is particularly important for certain " + "applications like multi-frame denoising or super-resolution.\n\n" + "In fact, pixel and feature registration methods can complement " + "each other: an application could first obtain a coarse " + "registration using features and then refine the registration " + "using a pixel based method on the overlapping area of the " + "images.\n\n" + "The module implements classes derived from the abstract classes " + "[cv::reg::Map](classcv_1_1reg_1_1Map.md) or " + "[cv::reg::Mapper](classcv_1_1reg_1_1Mapper.md). The former models " + "a coordinate transformation between two reference frames, while " + "the later encapsulates a way of invoking a method that calculates " + "a Map between two images.\n\n" + "Each class derived from Map implements a motion model, as " + "follows:\n\n" + "- [MapShift](classcv_1_1reg_1_1MapShift.md): Models a simple " + "translation\n" + "- [MapAffine](classcv_1_1reg_1_1MapAffine.md): Models an affine " + "transformation\n" + "- [MapProjec](classcv_1_1reg_1_1MapProjec.md): Models a projective " + "transformation\n\n" + "The classes derived from Mapper are:\n\n" + "- [MapperGradShift](classcv_1_1reg_1_1MapperGradShift.md): " + "Gradient based alignment for calculating translations.\n" + "- [MapperGradEuclid](classcv_1_1reg_1_1MapperGradEuclid.md): " + "Gradient based alignment for euclidean motions (rotations and " + "translations).\n" + "- [MapperGradSimilar](classcv_1_1reg_1_1MapperGradSimilar.md): " + "Gradient based alignment for similarities (euclidean motion plus " + "scaling).\n" + "- [MapperGradAffine](classcv_1_1reg_1_1MapperGradAffine.md): " + "Gradient based alignment for an affine motion model.\n" + "- [MapperGradProj](classcv_1_1reg_1_1MapperGradProj.md): Gradient " + "based alignment for calculating projective transformations.\n" + "- [MapperPyramid](classcv_1_1reg_1_1MapperPyramid.md): Implements " + "hierarchical motion estimation using a Gaussian pyramid."), + "classes": [ + ("class", "cv::reg::Map", + "Base class for modelling a Map between two images."), + ("class", "cv::reg::MapAffine", ""), + ("class", "cv::reg::Mapper", + "Base class for modelling an algorithm for calculating a map."), + ("class", "cv::reg::MapperGradAffine", ""), + ("class", "cv::reg::MapperGradEuclid", ""), + ("class", "cv::reg::MapperGradProj", ""), + ("class", "cv::reg::MapperGradShift", ""), + ("class", "cv::reg::MapperGradSimilar", ""), + ("class", "cv::reg::MapperPyramid", ""), + ("class", "cv::reg::MapProjec", ""), + ("class", "cv::reg::MapShift", ""), + ("class", "cv::reg::MapTypeCaster", ""), + ], + "functions": [], + }, + "cannops": { + "title": "Ascend-accelerated Computer Vision", + "include": "opencv2/cann.hpp", + "fn_include": "opencv2/cann_interface.hpp", + "description": ( + "This module provides Ascend-accelerated implementations of core " + "Computer-Vision operations, offloading element-wise arithmetic " + "and image-processing primitives to Huawei Ascend NPU hardware for " + "high-throughput acceleration."), + "topics": ["Core part", "Operations for Ascend Backend."], + "classes": [], + "functions": [], + }, +} + + +def _fallback_fn_anchor(name: str, idx: int) -> str: + """Stable in-page anchor for a fallback function's detail block.""" + return f"api-fn-{name}-{idx}" + + +def _fallback_topic_name(module: str, topic: str) -> str: + """Docname/anchor stem for a topic subpage (e.g. datasets_action_recognition).""" + return f"{module}_" + re.sub(r"[^a-z0-9]+", "_", topic.lower()).strip("_") + + +def _fallback_class_refid(kind: str, qualified: str) -> str: + """Doxygen-style page stem for a class/struct (e.g. + `classcv_1_1datasets_1_1Dataset`) so the class link mirrors the convention.""" + pfx = kind if kind in ("class", "struct", "union") else "class" + return pfx + qualified.replace("::", "_1_1") + + +def _render_fallback_body(name: str, d: dict) -> str: + """Full group-page body for a module with no Doxygen XML, in Doxygen's group + order: Topics, Detailed Description, Classes, Functions, Function + Documentation. Every link is a working `#include` file page, a generated + topic/class subpage, or an in-page anchor — so nothing 404s.""" + lines: list = [] + topics = d.get("topics") or [] + classes = d.get("classes") or [] + funcs = d.get("functions") or [] + if topics: + lines += ["## Topics", ""] + lines += [f"- [{t}](#api_{_fallback_topic_name(name, t)})" + for t in topics] + [""] + if d.get("description"): + lines += ["## Detailed Description", "", d["description"], ""] + if classes: + lines += ["## Classes", "", "{.api-reference-table}", + "| Name | Description |", "|---|---|"] + for kind, qual, brief in classes: + page = _fallback_class_refid(kind, qual) + link = f"[`{kind} {qual}`]({page}.md)" + # "More…" links to the class page's detail, as on the real pages — + # only where there's a brief (matching Doxygen). + desc = _md_escape_cell(brief) + if brief: + desc += f" [More…]({page}.md#detailed-description)" + lines.append(f"| {link} | {desc or chr(0xa0)} |") + lines += [""] + if funcs: + lines += ["## Functions", "", + "{.api-reference-table .api-function-table}", + "| Return | Name | Description |", "|---|---|---|"] + for i, (ret, qual, _args, brief) in enumerate(funcs): + anc = _fallback_fn_anchor(name, i) + lines.append(f"| `{_md_escape_cell(ret)}` | " + f"[`{qual}`](#{anc}) | {_md_escape_cell(brief)} |") + lines += [""] + if funcs: + lines += ["## Function Documentation", ""] + fn_inc = d.get("fn_include") or d.get("include") + href = _include_page_href(fn_inc) if fn_inc else None + for i, (ret, qual, args, brief) in enumerate(funcs): + short = qual.rsplit("::", 1)[-1] + lines += [f"({_fallback_fn_anchor(name, i)})=", + f"### {short}()", "", + "{.opencv-api-sig}", f"`{ret} {qual}{args}`", ""] + if href: + lines += [ + "{.opencv-api-include}", + f'<code class="docutils literal notranslate">' + f'#include <<a class="reference external ' + f'opencv-include-link" href="{href}">{fn_inc}</a>></code>', + ""] + if brief: + lines += [brief, ""] + # Hidden toctree registers the topic + class subpages in the sidebar nav + # (and avoids "not in any toctree" warnings); the lists above link to them. + toc = [_fallback_topic_name(name, t) for t in topics] + toc += [_fallback_class_refid(k, q) for k, q, _b in classes] + if toc: + lines += ["```{toctree}", ":hidden:", ""] + toc + ["```", ""] + return "\n".join(lines) + + +def _fallback_module_tree(name: str): + """In-memory stand-in for a module group whose Doxygen XML is missing. + Carries `body_md` (the full hand-rendered page body); `_write_api_stub` + emits it verbatim so the page has Topics/Classes/Functions/Function + Documentation with working links, and its `{#api_<name>}` anchor resolves + for any reference to the module. `topic_children` are the topic subpages + `_write_api_stub` also writes. Other fields keep the node-schema shape so + `module_rows`/`_flatten`/the index treat it like a real module. + + No top-of-page `#include` — like the real Doxygen group page, the include + belongs in the Function Documentation (where it links to the file page).""" + d = _FALLBACK_MODULE_DATA.get(name) + if d is None: + return None + title = d["title"] + back = f"Part of the [{title}](#api_{name}) module." + child_pages: list = [] + # Topic subpages (linked from the Topics list). + for t in (d.get("topics") or []): + cn = _fallback_topic_name(name, t) + child_pages.append((cn, "\n".join( + [f"# {t} {{#api_{cn}}}", "", back, ""]))) + # Class/struct subpages (linked from the Classes table; the brief lives + # under a Detailed Description heading so it reads like a class page). + for kind, qual, brief in (d.get("classes") or []): + crefid = _fallback_class_refid(kind, qual) + child_pages.append((crefid, "\n".join( + [f"# {kind.capitalize()} {qual}", "", + (brief or ""), "", + "## Detailed Description", "", + (brief or f"`{qual}` reference."), "", back, ""]))) + return { + "name": name, # single-underscore group name -> page & anchor + "title": title, + "detailed": "", + "innerclasses": [], + "sections": {}, + "children": [], + "child_pages": child_pages, + "body_md": _render_fallback_body(name, d), + } + + def _generate_api_stubs(modules, xml_dir, out_dir, root_anchor="api_root", root_title="API Reference", - root_desc=None): + root_desc=None, extra_groups=()): """Generate a stub tree (group/namespace/class pages) under out_dir. Docnames are prefixed with out_dir.name so the same generator drives both - main_modules/ and extra_modules/ (contrib) trees from separate calls.""" - if not modules: + main_modules/ and extra_modules/ (contrib) trees from separate calls. + extra_groups: orphan group stems rendered as pages but kept out of the + module index (referenced groups not nested under any module top).""" + if not modules and not extra_groups: return if not xml_dir.is_dir(): return # No XML yet; degrade silently. + global _CUR_MODULE_DIR + _CUR_MODULE_DIR = out_dir.name _doc_prefix = out_dir.name # Where the member renderers find legacy graph SVGs / write their variants. @@ -1176,13 +2411,26 @@ def _generate_api_stubs(modules, xml_dir, out_dir, global_ns_group_map: dict[str, set] = {} trees: list = [] module_rows: list = [] # (folder, page_stem, title) for the api_root list - for m in modules: + _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 stem = _module_group_stem(m) tree = _build_api_hierarchy("group__" + stem.replace("_", "__"), xml_dir) + if tree is None: + # No Doxygen XML for this module — inject a placeholder tree for the + # known optional modules so they still render & link; else skip. + tree = _fallback_module_tree(m) if tree is None: continue trees.append(tree) - module_rows.append((m, tree["name"], tree["title"])) + if is_extra: + pass + elif m == "gapi": + # gapi: expose as top-level "Graph API" wrapper page, not "G-API framework" + _gapi_tree = tree + module_rows.append((m, "gapi", "Graph API")) + else: + module_rows.append((m, tree["name"], tree["title"])) all_group_names = _collect_all_group_names(tree) all_refids = ["group__" + n.replace("_", "__") for n in all_group_names] for ns_name, grps in _build_ns_group_map(all_refids, xml_dir).items(): @@ -1202,7 +2450,8 @@ def _generate_api_stubs(modules, xml_dir, out_dir, anchor = f"api_ns_{ns['name'].replace('::', '__')}" if ns["name"] not in written_ns: _write_namespace_stub(ns, out_dir, xml_dir, - global_ns_group_map, global_group_info) + global_ns_group_map, global_group_info, + classes_seen) written_ns.add(ns["name"]) _ALL_NAMESPACES[ns["name"]] = { "refid": ns.get("refid", ""), @@ -1212,6 +2461,38 @@ def _generate_api_stubs(modules, xml_dir, out_dir, } ns_map.setdefault(group_name, []).append((ns["name"], anchor)) _write_api_stub(tree, out_dir, classes_seen, ns_map) + # Expand to NESTED classes (a class's own inner types, e.g. + # cv::ImageCollection::iterator) so each gets its own page like Doxygen — + # the group-level collection skips names containing `::`. + import xml.etree.ElementTree as _ET_nested + _queue = list(classes_seen.keys()) + while _queue: + _rid = _queue.pop() + _cx = xml_dir / f"{_rid}.xml" + if not _cx.is_file(): + continue + try: + _ccd = _ET_nested.parse(str(_cx)).getroot().find("compounddef") + except _ET_nested.ParseError: + _ccd = None + if _ccd is None: + continue + for _ic in _ccd.findall("innerclass"): + _icid = _ic.get("refid", "") + if not _icid or _icid in classes_seen: + continue + _icname = (_ic.text or "").strip() + _ickind = ("struct" if _icid.startswith("struct") + else "union" if _icid.startswith("union") else "class") + classes_seen[_icid] = {"refid": _icid, "name": _icname, + "qualified": _icname, "kind": _ickind} + _queue.append(_icid) + # Pre-seed every class's docname BEFORE writing, so cross-references made + # while rendering one page (e.g. "inherited from <base>" links) resolve even + # when the base class is written later in this same pass. + for _cls in classes_seen.values(): + _ANCHOR_TO_DOC.setdefault( + _cls["refid"], f"{_doc_prefix}/{_class_page_name(_cls['refid'])}") # Per-class pages; seed `_ANCHOR_TO_DOC` refid→docname for `@ref`. for cls in classes_seen.values(): _write_class_stub(cls, out_dir, xml_dir) @@ -1225,6 +2506,26 @@ def _generate_api_stubs(modules, xml_dir, out_dir, } # Placeholder stubs for bare template params (_Tp, …) so diagram links resolve. _write_placeholder_stubs(out_dir, xml_dir) + + # Write standalone "Graph API" page for gapi module (replaces gapi_ref as entry) + if _gapi_tree is not None: + content = _read_gapi_full_content() + # Collect all gapi subgroup page names for the hidden toctree + all_gapi_names = _collect_all_group_names(_gapi_tree) + gapi_toctree = "\n".join(all_gapi_names) + gapi_lines = ["# Graph API {#api_gapi}", ""] + if content: + gapi_lines += [content, ""] + gapi_lines += [ + "```{toctree}", ":hidden:", ":maxdepth: 2", "", + gapi_toctree, + "```", "", + ] + _stub_write(out_dir / "gapi.md", "\n".join(gapi_lines) + "\n") + + # File-reference pages (include dependency graphs) so #include links land on + # a Sphinx diagram page. + _write_file_ref_stubs(out_dir, xml_dir) # Hidden toctree drives nav/sidebar; the visible list shows "folder. Title". root_lines += ["```{toctree}", ":hidden:", ":maxdepth: 1", ""] root_lines += [stem for _m, stem, _t in module_rows] diff --git a/docs_sphinx/conf_helpers/translate.py b/docs_sphinx/conf_helpers/translate.py index 587901a3a2..53eecb4ef7 100644 --- a/docs_sphinx/conf_helpers/translate.py +++ b/docs_sphinx/conf_helpers/translate.py @@ -164,6 +164,12 @@ def _translate(text: str, docname: str | None = None) -> str: lambda m: f"# {m.group('title').strip()}", text, flags=re.MULTILINE) + # 1c2. Setext H2s starting with digit+dot ("1. Moments\n---") -> ATX ##. + text = re.sub( + r"^(?P<title>\d+\.[^\n]+?)[ \t]*\n-[-]{2,}[ \t]*$", + lambda m: f"## {m.group('title').strip()}", + text, flags=re.MULTILINE) + # 1d. Demote every H1 after the first to H2. def _demote_extra_h1s(src: str) -> str: fence_open_re = re.compile(r'^[ \t]*(?:`{3,}|~{3,})') @@ -197,9 +203,46 @@ def _translate(text: str, docname: str | None = None) -> str: (len(l) - len(l.lstrip()) for l in lines if l.strip()), default=0) body = "\n".join(l[min_ind:] for l in lines).strip() return f"\n:::{{{kind}}}\n{body}\n:::\n" + _ac_stash: dict[str, str] = {} + def _ac_hide(m: re.Match) -> str: + k = f"\x00AC{len(_ac_stash)}\x00"; _ac_stash[k] = m.group(0); return k + _t = re.sub(r"@code(?:\{[^}]*\})?.*?@endcode", _ac_hide, text, flags=re.DOTALL) + _t = re.sub( + r"^[ \t]*@(?P<dir>note|see|warning|sa)[ \t]*\n?(?P<body>.+?)" + r"(?=\n[ \t]*\n|\n[ \t]*@[A-Za-z]|\Z)", + _admon_repl, _t, flags=re.DOTALL | re.MULTILINE) + for _k, _v in _ac_stash.items(): + _t = _t.replace(_k, _v) + text = _t + + # MyST definition list with "Parameters" heading. + def _inline_block_math(s: str) -> str: + if re.match(r"^\\f\[.+\\f\]$", s.strip()): + return s + return re.sub(r"\\f\[(.+?)\\f\]", lambda mm: f"${mm.group(1).strip()}$", s) + def _param_block_repl(m: re.Match) -> str: + result, has_param, cur_name, cur_desc = [], False, None, [] + for line in m.group(0).split("\n"): + pm = re.match(r"@param\s+(\S+)\s+(.*)", line.strip()) + rm = re.match(r"@return\s+(.*)", line.strip()) + if pm: + if cur_name: + result.append(f"`{cur_name}`\n: {_inline_block_math(' '.join(cur_desc))}") + cur_name, cur_desc, has_param = pm.group(1), [pm.group(2).strip()], True + elif rm: + if cur_name: + result.append(f"`{cur_name}`\n: {_inline_block_math(' '.join(cur_desc))}") + cur_name = None + result.append(f"*(return value)*\n: {_inline_block_math(rm.group(1).strip())}") + elif cur_name and line.strip(): + cur_desc.append(line.strip()) + if cur_name: + result.append(f"`{cur_name}`\n: {_inline_block_math(' '.join(cur_desc))}") + header = "\n**Parameters**\n\n" if has_param else "\n" + return header + "\n\n".join(result) + "\n" text = re.sub( - r"^[ \t]*@(?P<dir>note|see|warning|sa)[ \t]*\n?(?P<body>.+?)(?=\n[ \t]*\n|\n[ \t]*@[A-Za-z]|\Z)", - _admon_repl, text, flags=re.DOTALL | re.MULTILINE) + r"((?:^@(?:param\s+\S+|return)\s+[^\n]+\n(?:[ \t]+[^\n]+\n)*)+)", + _param_block_repl, text, flags=re.MULTILINE) # 2. Doxygen LaTeX math markers; preserve indent so blocks inside list items stay in the list. def _split_adj_math(m: re.Match) -> str: @@ -209,7 +252,11 @@ def _translate(text: str, docname: str | None = None) -> str: _split_adj_math, text, flags=re.MULTILINE) def _fblock(m: re.Match) -> str: ind = m.group("indent") - return f"\n{ind}$$\n{m.group('body').strip()}\n{ind}$$\n" + body = m.group("body").strip() + if "\\\\" in body: + body = re.sub(r"\n\s*\n", "\n", body) + return f"\n{ind}```{{math}}\n{ind}{body}\n{ind}```\n" + return f"\n{ind}$$\n{body}\n{ind}$$\n" text = re.sub(r"^(?P<indent>[ \t]*)\\f\[(?P<body>.+?)\\f\]", _fblock, text, flags=re.DOTALL | re.MULTILINE) text = re.sub(r"\\f\[(.+?)\\f\]", @@ -258,10 +305,17 @@ def _translate(text: str, docname: str | None = None) -> str: text, flags=re.MULTILINE) # 3d. \htmlonly ... \endhtmlonly -> `{raw} html`. + _depth = len(docname.split("/")) - 1 if docname else 1 + _to_root = "../" * _depth + def _htmlonly_repl(m: re.Match) -> str: + body = re.sub(r'src="(?:\.\./)+(?P<f>js_[^"]+)"', + lambda mm: f'src="{_to_root}js_tutorials/{mm.group("f")}"', + m.group("body")) + body = re.sub(r'\s*onload="[^"]*"', ' height="700px"', body) + return f"\n```{{raw}} html\n{body.strip()}\n```\n" text = re.sub( r"\\htmlonly\s*\n(?P<body>.*?)\n\s*\\endhtmlonly", - lambda m: f"\n```{{raw}} html\n{m.group('body').strip()}\n```\n", - text, flags=re.DOTALL) + _htmlonly_repl, text, flags=re.DOTALL) # 3e. Plain fences with Doxygen lang spec ("```.sh") -> strip dot, alias-map. text = re.sub( @@ -357,7 +411,61 @@ def _translate(text: str, docname: str | None = None) -> str: r"@link\s+(?P<target>[\w-]+)(?P<disp>.*?)@endlink", _link_repl, text, flags=re.DOTALL) - if docname and (docname.startswith("main_modules/") or docname.startswith("extra_modules/")): + # API stub rewrites — extended from `api/core_basic` to every `api/` + # page so the Functions/Typedefs detail blocks on `core_array`, + # `core_utils`, `core_cluster`, … get the same per-token token + # linkifier (step 8g) that turns `InputArray`, `OutputArray`, + # `Mat`, `_Tp`, etc. inside the signature codespans into individual + # `<a>` anchors. The pre-existing regex-driven steps (8a, 8b, 8e, + # 8i, 8j and the Vec-rows / cv::Ptr rewrites) are no-ops on pages + # whose markdown doesn't match their patterns, so widening the gate + # is safe. + if docname and (docname.startswith("api/") + or docname.startswith("main_modules/") + or docname.startswith("extra_modules/")): + # Idempotency guard: the stub generator now emits the + # "Shorter aliases for the most popular specializations of + # Vec<T,n>" section itself (via the `@name` named-group path + # in `_summary_block`), so the source MD already contains + # both the heading and the Vec rows. The legacy rewrite + # below also extracts the rows and injects a fresh section + # before "## Typedef Documentation" — without this guard the + # heading renders TWICE, the first instance an empty table. + # Skip the rewrite when the source MD already has the + # heading. + if "## Shorter aliases for the most popular specializations of Vec<T,n>" not in text: + _vec_rows_re = re.compile( + r"(?:^\| `Vec<[^`]*` \| [^\n]*\n)+", re.MULTILINE) + _vm = _vec_rows_re.search(text) + if _vm: + _vec_rows = _vm.group(0) + text = text[:_vm.start()] + text[_vm.end():] + _shorter = ( + "## Shorter aliases for the most popular specializations of " + "Vec<T,n>\n\n" + # Carry the `.api-typedef-table` class so the section + # inherits the same table styling (and the light-mode + # blue Type-cell anchor rule) as the main Typedefs table + # above. + "{.api-typedef-table}\n" + "| Type | Name | Description |\n" + "|---|---|---|\n" + + _vec_rows + "\n") + text = text.replace( + "## Typedef Documentation", + _shorter + "## Typedef Documentation", + 1) + + # 8c. `{doxygentypedef} cv::Ptr` -> hand-rolled cpp:type (breathe skips C++11 aliases). + text = re.sub( + r"```\{doxygentypedef\} cv::Ptr\s*\n:project: opencv\s*\n```", + "```{eval-rst}\n" + ".. cpp:namespace:: cv\n" + ".. cpp:type:: template<typename _Tp> Ptr = std::shared_ptr<_Tp>\n" + "```", + text) + + # 8e. Classes table rows: append template params + "View details" link. def _rewrite_class_row(m: re.Match) -> str: kind = m.group("kind") name = m.group("name") # 'cv::dnn::BackendNode' @@ -486,34 +594,96 @@ def _translate(text: str, docname: str | None = None) -> str: r'(?P<inner>.*?)(?P<close></code>)', _linkify_inside_code, text, flags=re.DOTALL) - def _linkify_markdown_codespan(m: re.Match) -> str: - content = m.group("content") - # Prefix-aware: `cv::Name` is one hit so the anchor covers both. - hits = [(t.start(), t.end(), t.group(0)) for t in - _tok_re.finditer(content) - if _token_url(_bare(t.group(0)))] - if not hits: + # 8g. Linkify recognized type tokens in code spans across every API + # page (Functions summary + Function Documentation detail blocks). + # Was previously gated to `main_modules/core_basic` only — that + # left parameter types in other module pages (calib, dnn, …) as + # plain code chips even though their local typedef/class targets + # exist. Pass 1 walks any existing `<code>` HTML emitted by + # `_func_row_split_md` (function name already wrapped in `<a>`; + # remaining text tokens get individual anchors). Pass 2 walks + # remaining markdown code spans for any rows still using the + # backticked-signature form. + if (docname and (docname.startswith("api/") + or docname.startswith("main_modules/") + or docname.startswith("extra_modules/")) + and (_LOCAL_CLASS_URL or _LOCAL_TYPEDEF_URL)): + def _token_url(tok: str) -> str | None: + # Tokens absent from the tagfile stay plain. + return _LOCAL_CLASS_URL.get(tok) or _LOCAL_TYPEDEF_URL.get(tok) + # Match an optional `cv::` prefix so the anchor spans `cv::Name`. + _tok_re = re.compile(r"(?:cv::)?_?[A-Za-z][A-Za-z0-9_]*") + def _bare(tok: str) -> str: + return tok[4:] if tok.startswith("cv::") else tok + def _anchor_text(tok: str) -> str: + # Encode `::` so the later cv-linkifier doesn't nest a second anchor. + return tok.replace("::", "::") + def _linkify_html_segment(seg: str) -> str: + def _sub(m: re.Match) -> str: + url = _token_url(_bare(m.group(0))) + if not url: return m.group(0) - from html import escape as _esc - parts, last = [], 0 - for s, e, tok in hits: - parts.append(_esc(content[last:s])) - parts.append(f'<a class="reference internal" ' - f'href="{_token_url(_bare(tok))}">' - f'{_anchor_text(tok)}</a>') - last = e - parts.append(_esc(content[last:])) - return (f'<code class="docutils literal notranslate">' - f'{"".join(parts)}</code>') - _masked: list[str] = [] - def _mask(m: re.Match) -> str: - _masked.append(m.group(0)) - return f"\x00MDLINK{len(_masked)-1}\x00" - text = re.sub(r"\[(?:`[^`\n]+`|<br>)+\]\([^)\n]+\)", _mask, text) - text = re.sub(r"`(?P<content>[^`\n]+?)`", - _linkify_markdown_codespan, text) - text = re.sub(r"\x00MDLINK(\d+)\x00", - lambda m: _masked[int(m.group(1))], text) + return (f'<a class="reference internal" ' + f'href="{url}">{_anchor_text(m.group(0))}</a>') + return _tok_re.sub(_sub, seg) + # Pass 1: walk every `<code class="docutils literal notranslate">…</code>` + # block; skip any `<a>` already inside (function-name anchor from + # `_func_row_split_md`), linkify recognized tokens in the rest. + def _linkify_inside_code(m: re.Match) -> str: + inner = m.group("inner") + out, i, n = [], 0, len(inner) + while i < n: + if inner.startswith("<a ", i): + j = inner.find("</a>", i) + if j < 0: + out.append(inner[i:]); break + out.append(inner[i:j + 4]); i = j + 4 + else: + k = inner.find("<a ", i) + if k < 0: + out.append(_linkify_html_segment(inner[i:])); break + out.append(_linkify_html_segment(inner[i:k])); i = k + return m.group("open") + "".join(out) + m.group("close") + text = re.sub( + r'(?P<open><code class="docutils literal notranslate">)' + r'(?P<inner>.*?)(?P<close></code>)', + _linkify_inside_code, text, flags=re.DOTALL) + # Pass 2: remaining markdown code spans — the Type-column typedef + # chips (`InputArray`, `Mat_<uchar>`, `const _InputArray &`, …) + # and any other backticked tokens that MyST only turns into + # `<code><span class="pre">…</span></code>` at render time, AFTER + # this step has run (so pass 1's `<code>` walk can't reach them, + # and postprocess's inline walker can't either — Sphinx's + # `<span class="pre">` wrapper defeats its body regex). Markdown + # links are masked first so anchored Name-column cells stay intact. + def _linkify_markdown_codespan(m: re.Match) -> str: + content = m.group("content") + # Prefix-aware: `cv::Name` is one hit so the anchor covers both. + hits = [(t.start(), t.end(), t.group(0)) for t in + _tok_re.finditer(content) + if _token_url(_bare(t.group(0)))] + if not hits: + return m.group(0) + from html import escape as _esc + parts, last = [], 0 + for s, e, tok in hits: + parts.append(_esc(content[last:s])) + parts.append(f'<a class="reference internal" ' + f'href="{_token_url(_bare(tok))}">' + f'{_anchor_text(tok)}</a>') + last = e + parts.append(_esc(content[last:])) + return (f'<code class="docutils literal notranslate">' + f'{"".join(parts)}</code>') + _masked: list[str] = [] + def _mask(m: re.Match) -> str: + _masked.append(m.group(0)) + return f"\x00MDLINK{len(_masked)-1}\x00" + text = re.sub(r"\[(?:`[^`\n]+`|<br>)+\]\([^)\n]+\)", _mask, text) + text = re.sub(r"`(?P<content>[^`\n]+?)`", + _linkify_markdown_codespan, text) + text = re.sub(r"\x00MDLINK(\d+)\x00", + lambda m: _masked[int(m.group(1))], text) # 6c. Bullet lists of @subpage/@ref -> toctree + visible list. Runs BEFORE step 7. def _subpage_list_to_toctree(src: str) -> str: @@ -606,6 +776,17 @@ def _translate(text: str, docname: str | None = None) -> str: text = re.sub(r'@ref\s+(?P<name>[\w:-]+)(?:\s+"(?P<disp>[^"]+)")?', _ref_repl, text) + # 7c. cv.Name -> Markdown link using _CV_SYMBOL_URL; skips code spans. + if _CV_SYMBOL_URL: + def _cvlink_repl(m: re.Match) -> str: + url = _CV_SYMBOL_URL.get(m.group(1)) + return f'[cv.{m.group(1)}]({url})' if url else m.group(0) + _parts = re.split(r'(```.*?```|`[^`\n]+`)', text, flags=re.DOTALL) + text = ''.join( + p if i % 2 else re.sub( + r'(?<!\[)(?<!\()cv\.([A-Za-z][A-Za-z0-9_]*)', _cvlink_repl, p) + for i, p in enumerate(_parts)) + # 8. @cite KEY -> `[N]` HTML anchor to citelist (N from opencv.bib order). def _cite_repl(m: re.Match) -> str: key = m.group("key") @@ -668,6 +849,19 @@ def _translate(text: str, docname: str | None = None) -> str: return pat.sub(repl, src) text = _dedent_subpage_descriptions(text) + # 9z. Doxygen group/member URLs -> local cross-ref when we built that page. + def _doxy_to_local(m: re.Match) -> str: + base, anchor = m.group("base"), m.group("anchor") + if anchor: + tgt = f"{base}_1{anchor}" + return f"](#{tgt})" if tgt in _LOCAL_MEMBER_IDS else m.group(0) + tgt = f"api_{base[len('group__'):].replace('__', '_')}" + return f"](#{tgt})" if tgt in _ANCHOR_TO_DOC else m.group(0) + text = re.sub( + r"\]\(" + re.escape(DOXYGEN_BASE_URL) + + r"(?:[\w-]+/)*?(?P<base>group__\w+)\.html(?:#(?P<anchor>\w+))?\)", + _doxy_to_local, text) + # 10. @next_tutorial / @prev_tutorial -> drop text = re.sub(r"^@(?:next|prev)_tutorial\{[^}]*\}\s*$", "", text, flags=re.MULTILINE) @@ -766,7 +960,20 @@ def _translate(text: str, docname: str | None = None) -> str: r'!\[(?P<alt>[^\]]*)\]\((?:[^)]*?/)?(?P<dir>images|js_assets)/(?P<rel>[^)]+)\)', _img_repl, text) - # 12b. Cross-tree contrib image refs -> raw-HTML <img>, depth-relative URL. + # 12b. Bare image filename (no dir prefix, e.g. "shape.jpg") -> _IMAGE_INDEX lookup. + def _bare_img_repl(m: re.Match) -> str: + rel = m.group("rel") + if docname: + local = DOC_ROOT / pathlib.Path(docname).parent / "images" / rel + if local.is_file(): + return f'{m.group("pre")}images/{rel})' + hit = _IMAGE_INDEX.get(rel) + return f'{m.group("pre")}/{hit})' if hit else m.group(0) + text = re.sub( + r'(?P<pre>!\[[^\]]*\]\()(?P<rel>[A-Za-z0-9_.-]+\.[A-Za-z]{2,4})\)', + _bare_img_repl, text) + + # 12c. Cross-tree contrib image refs -> raw-HTML <img>, depth-relative URL. def _img_xtree(m: re.Match) -> str: alt, rel = m.group("alt"), m.group("rel") if rel.startswith("/") or "://" in rel: diff --git a/docs_sphinx/conf_helpers/xml_render.py b/docs_sphinx/conf_helpers/xml_render.py index 517ac92657..84f92725ce 100644 --- a/docs_sphinx/conf_helpers/xml_render.py +++ b/docs_sphinx/conf_helpers/xml_render.py @@ -121,28 +121,50 @@ def _member_detail_parts(md): if de is None: return "", [], "" params, returns = [], "" - for pl in de.iter("parameterlist"): - if pl.get("kind") in ("param", "templateparam"): - for it in pl.findall("parameteritem"): - nm = ", ".join( - t for t in (_itertext(n) for n in - it.findall(".//parametername")) if t) - d = _doxygen_desc_to_md(it.find("parameterdescription")) - if nm: - params.append((nm, d)) - for ss in de.iter("simplesect"): - if ss.get("kind") == "return": - returns = _itertext(ss) + for para in de.findall("para"): + for pl in para.findall("parameterlist"): + if pl.get("kind") in ("param", "templateparam"): + for it in pl.findall("parameteritem"): + nm = ", ".join( + t for t in (_itertext(n) for n in + it.findall(".//parametername")) if t) + # Block-aware: a description carrying an <itemizedlist> + # (e.g. calibration `flags`) keeps its bullets as real + # Markdown instead of collapsing into a run-on paragraph. + d = _doxygen_desc_to_md(it.find("parameterdescription")) + if nm: + params.append((nm, d)) + for ss in para.findall("simplesect"): + if ss.get("kind") == "return": + returns = _itertext(ss) + # Prune the param/return chrome (rendered separately) then convert the rest + # with full block support so lists and notes survive. Preserve tail text when + # removing elements so descriptions after parameterlist are not lost. pruned = _copy.deepcopy(de) - def _strip(el): - for child in list(el): - if child.tag == "parameterlist" or ( - child.tag == "simplesect" - and child.get("kind") in ("param", "templateparam", "return")): - el.remove(child) - else: - _strip(child) - _strip(pruned) + for para in pruned.findall("para"): + for child in list(para): + if child.tag == "parameterlist": + # Preserve tail text (text after the element) by moving it to previous sibling + if child.tail: + prev_idx = list(para).index(child) - 1 + if prev_idx >= 0: + prev_sibling = para[prev_idx] + prev_sibling.tail = (prev_sibling.tail or "") + child.tail + else: + # No previous sibling, prepend to para text + para.text = (para.text or "") + child.tail + para.remove(child) + elif (child.tag == "simplesect" + and child.get("kind") in ("param", "templateparam", "return")): + # Same tail preservation for simplesect + if child.tail: + prev_idx = list(para).index(child) - 1 + if prev_idx >= 0: + prev_sibling = para[prev_idx] + prev_sibling.tail = (prev_sibling.tail or "") + child.tail + else: + para.text = (para.text or "") + child.tail + para.remove(child) return _doxygen_desc_to_md(pruned), params, returns @@ -213,6 +235,8 @@ def _parse_member_sections(cd) -> dict[str, list[dict]]: "kind": kind, "name": (md.findtext("name") or "").strip(), "qualified": qualified, + "definition": (md.findtext("definition") or "").strip(), + "virt": md.get("virt", "non-virtual"), "type": _itertext(md.find("type")), "args": (md.findtext("argsstring") or "").strip(), "param_types": param_types, @@ -258,6 +282,8 @@ def _build_api_hierarchy(refid: str, xml_dir: pathlib.Path, return None name = (cd.findtext("compoundname") or "").strip() title = (cd.findtext("title") or name).strip() + brief_el = cd.find("briefdescription") + brief = _doxygen_desc_to_md(brief_el).strip() if brief_el is not None else "" detailed_el = cd.find("detaileddescription") detailed = _doxygen_desc_to_md(detailed_el) if detailed_el is not None else "" # Inner classes (public only). One read per class's XML for its brief. @@ -282,7 +308,7 @@ def _build_api_hierarchy(refid: str, xml_dir: pathlib.Path, child = _build_api_hierarchy(ig.get("refid"), xml_dir, _seen) if child is not None: children.append(child) - return {"name": name, "title": title, "detailed": detailed, + return {"name": name, "title": title, "brief": brief, "detailed": detailed, "innerclasses": innerclasses, "sections": sections, "children": children} @@ -326,25 +352,242 @@ def _doxygen_desc_to_md(el, h_level: int = 3) -> str: parts.append(child.tail) return "".join(parts) + # Doxygen <highlight class="…"> → Pygments token class, so the + # rendered <pre> picks up the existing `.highlight pre .k/.n/.kt/.s/.c` + # CSS styling and our `code > a { color: inherit }` rule won't dim the + # link colour. + _HL_PYG_CLASS = { + "keyword": "k", "keywordtype": "kt", "keywordflow": "k", + "preprocessor": "cp", "comment": "c", "comment-multiline": "cm", + "stringliteral": "s", "charliteral": "sc", + } + # Identifier-only token regex; used to linkify `InputArray`, + # `OutputArray`, `Mat`, `Scalar`, `DFT_INVERSE`, … left as plain + # text by Doxygen (no `<ref>` on them). + _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + # `#include "opencv2/…"` / `#include <opencv2/…>` regex. Doxygen + # wraps the WHOLE include directive (keyword + quoted/angled path) + # in one `<highlight class="preprocessor">` span — no separate + # token for the path — so the per-identifier linkifier above can't + # see the path. Match the include line and wrap just the path in + # an `<a>` to its local Doxygen file page (`_FILE_URL`). + _INCLUDE_PATH_RE = re.compile( + r'(#include\s*)(["<])([A-Za-z0-9_./+\-]+\.[A-Za-z0-9]+)([">])' + ) + def _programlisting(node) -> str: - lines = [] - for codeline in node.findall("codeline"): - lines.append("".join(_hl_text(hl) for hl in codeline.findall("highlight"))) - return "```cpp\n" + "\n".join(lines) + "\n```" + """Emit `<programlisting>` as a raw HTML `<pre>` block with + per-token `<a>` anchors. The user's spec: code listings must + be CLICKABLE — every recognised identifier (class/typedef/enum + member or `<ref>`-marked token) becomes a link to its local + Sphinx target. Falls back to plain text for unknown tokens, so + keywords/numbers/operators stay un-linked but still picked up + by the Pygments-style span classes for colouring.""" + from html import escape as _esc + out = ['<div class="highlight-cpp notranslate"><div class="highlight"><pre>'] + + def _linkify_plain(text: str) -> str: + """Wrap identifier runs that resolve via `_local_url` + with an `<a>`; everything else stays HTML-escaped text.""" + parts: list[str] = [] + last = 0 + for m in _IDENT_RE.finditer(text): + if m.start() > last: + parts.append(_esc(text[last:m.start()])) + tok = m.group(0) + url = _LOCAL_CLASS_URL.get(tok) or _LOCAL_TYPEDEF_URL.get(tok) + if url: + parts.append( + f'<a class="reference internal" href="{url}">' + f'<span class="n">{_esc(tok)}</span></a>') + else: + parts.append(_esc(tok)) + last = m.end() + parts.append(_esc(text[last:])) + return "".join(parts) + + def _linkify_include_path(escaped: str) -> str: + """Find `#include "opencv2/…"` patterns in already-escaped + text and wrap the path in an `<a>` to its Doxygen file + page. Operates on POST-escape text (where `"` is `"` + and `<>` are `<`/`>`).""" + def _sub(m: re.Match) -> str: + kw, openq, path, closeq = m.groups() + file_url = _FILE_URL.get(path) + if not file_url: + return m.group(0) + href = f"../../../doc/doxygen/html/{file_url}" + return (f'{kw}{openq}' + f'<a class="reference external opencv-include-link" ' + f'href="{href}">{path}</a>{closeq}') + # The escape map converts `"` → `"`, `<` → `<`, + # `>` → `>`; rewrite the regex once for that form. + def _re_escaped() -> "re.Pattern": + return re.compile( + r'(#include\s*)("|<)' + r'([A-Za-z0-9_./+\-]+\.[A-Za-z0-9]+)' + r'("|>)' + ) + return _re_escaped().sub(_sub, escaped) + + def _emit_highlight(hl) -> str: + """One `<highlight class="…">` → optionally-wrapped span + sequence preserving inline `<ref>` link targets.""" + klass = _HL_PYG_CLASS.get(hl.get("class", ""), "") + segs: list[str] = [] + if hl.text: + segs.append(_linkify_plain(hl.text)) + for child in hl: + if child.tag == "sp": + segs.append(" ") + elif child.tag == "ref": + inner = "".join(child.itertext()) + url = _local_url(child.get("refid", ""), inner) + if url: + segs.append( + f'<a class="reference internal" href="{url}">' + f'<span class="n">{_esc(inner)}</span></a>') + else: + segs.append(_linkify_plain(inner)) + else: + segs.append(_linkify_plain("".join(child.itertext()))) + if child.tail: + segs.append(_linkify_plain(child.tail)) + body = "".join(segs) + # Preprocessor lines: linkify any `#include "path"` AFTER + # the per-identifier pass (Doxygen lumps the whole include + # directive into one `class="preprocessor"` span, so the + # path can only be found at the assembled-body level). + if klass == "cp": + body = _linkify_include_path(body) + # Wrap in a Pygments-style `<span class="X">` only for token + # kinds whose colouring would otherwise be lost. The "normal" + # highlight class is the un-coloured default — emit without + # the outer span so embedded `<a>` link colours win. + if klass and body: + return f'<span class="{klass}">{body}</span>' + return body + + codelines = node.findall("codeline") + for i, cl in enumerate(codelines): + parts = [_emit_highlight(hl) for hl in cl.findall("highlight")] + line = "".join(parts) + # Empty codeline → emit `<span></span>` placeholder so the + # blank line doesn't terminate the surrounding raw-HTML + # block under CommonMark rule 7. + out.append(line if line else "<span></span>") + out.append('</pre></div></div>') + # Wrap the whole thing in a `\n` so MyST parses it as a raw + # HTML block (CommonMark type 6) — the leading line is `<div…>` + # which qualifies. Trailing blank line ends the block. + return "\n".join(out) + "\n" def _ref_link(refid: str, text: str) -> str: - if not (refid and text): - return f"`{text}`" if text else "" - m = re.search(r'_1([a-z]{1,3}[0-9a-f]{20,})$', refid) - if m: - url = f"{DOXYGEN_BASE_URL}{refid[:m.start()]}.html#{m.group(1)}" - else: - url = f"{DOXYGEN_BASE_URL}{refid}.html" - return f"[`{text}`]({url})" + """Inline `<ref>` → blue link to a LOCAL Sphinx target. + The user's spec for cross-references: "blue text, no grey + chip, redirect to local pages, no off-site bounce". So: + - LOCAL URLs only via `_local_url(refid, text)`; when no + local target exists we drop the link (return plain text) + instead of routing the reader to docs.opencv.org. + - emitted as RAW HTML (`<a class="reference internal" …>`) + rather than markdown `[text](url)`. Markdown link syntax + in MyST/Sphinx with a `#fragment` URL is interpreted as a + pending domain xref — when the fragment doesn't match a + registered domain target the result is `<span class="xref + myst">text</span>` (unresolved) plus a `#` prefix on the + already-`#` href. Raw HTML bypasses the xref resolver so + the anchor stays a plain in-page link. + - no backticks → no `<code>` grey chip and no 500-weight + on the text. + """ + if not text: + return "" + url = _local_url(refid, text) if refid else None + if not url: + return text + from html import escape as _esc_rl + return (f'<a class="reference internal" href="{url}">' + f'{_esc_rl(text)}</a>') + def _local_url(refid: str, name: str) -> str | None: + """Resolve a Doxygen `<ref>` to a Sphinx-local URL. + Layered lookup: + 1. Class/struct short-name → curated map (`Mat`, `InputArray`,…) + from `_LOCAL_CLASS_URL`/`_LOCAL_TYPEDEF_URL`. + 2. Class/struct member refid (`classcv_1_1Mat_1a<hex>`) → + class page + slugified member anchor. + 3. Group-anchored function refid (`group__core__array_1ga<hex>`) + → same-page slugified anchor (the function's detail block + emits `({refid})=` which MyST turns into `<span id="…">`). + 4. Bare class/struct compound refid → class page. + Returns None when nothing matches — caller renders plain text.""" + if not refid: + return None + # Try both the full name and its short form (after the last + # `::`). The typedef/class maps key on the short name only + # (e.g. `InputArray`, not `cv::InputArray`), so a `<ref>` whose + # text says `cv::InputArray` would otherwise miss this lookup + # and fall through to the function-slug branch below — minting + # a non-existent `#cv-inputarray` anchor instead of the real + # typedef target `core_basic.html#inputarray`. + short_name = name.rsplit("::", 1)[-1] if name else "" + direct = (_LOCAL_CLASS_URL.get(name) or _LOCAL_TYPEDEF_URL.get(name) + or _LOCAL_CLASS_URL.get(short_name) + or _LOCAL_TYPEDEF_URL.get(short_name)) + if direct: + return direct + # Class-member refid (long hex suffix). + cm = re.match( + r"^((?:class|struct)cv_1_1[A-Za-z0-9_]+?)_1" + r"([a-z]{1,3}[0-9a-f]{20,})$", refid) + if cm: + page = cm.group(1) + slug = re.sub(r"_+", "-", refid).lower() + return f"{page}.html#{slug}" + # Group-anchored member on the current group page. + # + # Doxygen uses two refid suffixes for group members: + # * `_1ga<hex>` — function in a group → anchor is the + # `_func_slug(name)` (cv-name) emitted by + # `_render_core_basic_func`. + # * `_1gga<hex>` — enum VALUE in a group → anchor is the + # C++ v4 id (`_CPPv4N…E`) emitted by the + # per-value `<span id>` in the enum + # detail table. The enum-type's parent + # name is captured via `_CV_SYMBOL_URL` + # elsewhere; here we use the value name + # + the parent enum name when available + # to mint the same id. + m = re.search(r"^group__(?P<grp>[A-Za-z0-9_]+?)_1(?P<kind>gga|ga)(?P<hex>[0-9a-f]+)$", refid) + if m: + kind = m.group("kind") + if kind == "ga": # function + short = (name.rsplit("::", 1)[-1] if name else "") + short = short.split("(", 1)[0].strip() + if not short: + return None + page_stem = m.group("grp").replace("__", "_") + return f"{page_stem}.html#{short.lower()}" + # Enum VALUE `gga<enumhash>a<valuehash>`: group pages carry no + # per-value anchor, so link to the enum TYPE's doc (refid ga<enumhash>). + hexv = m.group("hex") + if len(hexv) == 65 and hexv[32] == "a": + return _ENUM_REF_URL.get( + f"group__{m.group('grp')}_1ga{hexv[:32]}") + return None + # Bare class/struct compound page. + if refid.startswith(("classcv_1_1", "structcv_1_1")): + return f"{refid}.html" + return None _formula_md = _render_formula - _BLOCK_TAGS = {"orderedlist", "itemizedlist", "programlisting", "simplesect", "table"} + _BLOCK_TAGS = {"orderedlist", "itemizedlist", "programlisting", "simplesect", + "table", "xrefsect"} + + # Map missing 'see' and 'sa' Doxygen sections to MyST 'seealso' admonitions to fix unboxed reference rendering. + _ADMON_BY_KIND = {"note": "note", "warning": "warning", + "attention": "warning", "remark": "note", + "see": "seealso", "sa": "seealso"} def _emit_block(sub, result: list, level: int) -> None: t = sub.tag @@ -358,13 +601,20 @@ def _doxygen_desc_to_md(el, h_level: int = 3) -> str: result.append(f"- {_listitem_text(item)}") elif t == "simplesect": kind = sub.get("kind", "") - admon = {"note": "note", "warning": "warning", - "attention": "warning", "remark": "note"}.get(kind) + admon = _ADMON_BY_KIND.get(kind) body = "\n\n".join(_blocks(sub, level)) if admon: result.append(f":::{{{admon}}}\n{body}\n:::") elif body: result.append(body) + elif t == "xrefsect": + # @todo/@bug/@deprecated -> a titled admonition box (like Doxygen's). + title = (sub.findtext("xreftitle") or "Note").strip() + desc = sub.find("xrefdescription") + body = "\n\n".join(_blocks(desc, level)) if desc is not None else "" + if body: + result.append( + f":::{{admonition}} {title}\n:class: {title.lower()}\n\n{body}\n:::") elif t == "table": rows = sub.findall("row") if not rows: @@ -473,8 +723,7 @@ def _doxygen_desc_to_md(el, h_level: int = 3) -> str: elif t == "simplesect": # Merge consecutive simplesects of the same kind into one admonition. kind = child.get("kind", "") - admon = {"note": "note", "warning": "warning", - "attention": "warning", "remark": "note"}.get(kind) + admon = _ADMON_BY_KIND.get(kind) bodies = [] while (i < len(children) and children[i].tag == "simplesect" and children[i].get("kind", "") == kind): @@ -762,6 +1011,17 @@ def _read_class_data(refid: str, xml_dir: pathlib.Path) -> dict | None: "detailed": _dtl, "params": _params, "returns": _returns, + # (text, refid) for every <ref> in the return type, param types + # and default values — the exact tokens Doxygen hyperlinks in a + # signature (classes, typedefs, enum values), keyed by refid. + "sig_refs": [((r.text or "").strip(), r.get("refid", "")) + for el in ([md.find("type")] + + [c for p in md.findall("param") + for c in (p.find("type"), + p.find("defval"))]) + if el is not None + for r in el.iter("ref") + if (r.text or "").strip() and r.get("refid")], }) if items: sections[skind] = items @@ -772,12 +1032,15 @@ def _read_class_data(refid: str, xml_dir: pathlib.Path) -> dict | None: _itertext(p).strip() for p in detailed_el.findall("para") )) include = _normalize_include(cd.findtext("includes") or "") + bases = [(b.get("refid"), (b.text or "").strip(), b.get("prot", "public")) + for b in cd.findall("basecompoundref") if b.get("refid")] return { "name": (cd.findtext("compoundname") or "").strip(), "brief": _itertext(cd.find("briefdescription")), "detailed": has_detailed, "sections": sections, "include": include, + "bases": bases, } @@ -855,6 +1118,9 @@ def _svg_dark_variant(text: str) -> str: block = block.replace('fill="grey"', 'fill="none"') block = block.replace('fill="#999999"', 'fill="#2d333b"') block = block.replace('fill="#bfbfbf"', 'fill="#2d333b"') + # Non-clickable leaf boxes (std headers) are light grey #e0e0e0 — make + # them the same darker grey as the current-file box, white text below. + block = block.replace('fill="#e0e0e0"', 'fill="#2d333b"') block = block.replace('<text ', '<text fill="#ffffff" ') return block text = _re.sub(r'<g[^>]*class="node"[^>]*>.*?</g>', @@ -862,6 +1128,7 @@ def _svg_dark_variant(text: str) -> str: text = text.replace('fill="grey"', 'fill="none"') text = text.replace('fill="#999999"', 'fill="#2d333b"') text = text.replace('fill="#bfbfbf"', 'fill="#2d333b"') + text = text.replace('fill="#e0e0e0"', 'fill="#2d333b"') # Lookahead avoids double-prefixing per-node texts. text = _re.sub(r'<text (?!fill)', '<text fill="#ffffff" ', text) return text diff --git a/modules/dnn/include/opencv2/dnn/all_layers.hpp b/modules/dnn/include/opencv2/dnn/all_layers.hpp index 72568fabcd..f8580ac9ac 100644 --- a/modules/dnn/include/opencv2/dnn/all_layers.hpp +++ b/modules/dnn/include/opencv2/dnn/all_layers.hpp @@ -57,8 +57,8 @@ CV__DNN_INLINE_NS_BEGIN In addition to this way of layers instantiation, there is a more common factory API (see @ref dnnLayerFactory), it allows to create layers dynamically (by name) and register new ones. You can use both API, but factory API is less convenient for native C++ programming and basically designed for use inside importers (see @ref readNetFromTensorflow()). - Built-in layers partially reproduce functionality of corresponding ONNX, TensorFlow and Caffe layers. - In particular, the following layers and Caffe importer were tested to reproduce <a href="http://caffe.berkeleyvision.org/tutorial/layers.html">Caffe</a> functionality: + Built-in layers reproduce the functionality of the corresponding ONNX and TensorFlow operators. + The following layers are among the core building blocks used to assemble imported networks: - Convolution - Deconvolution - Pooling diff --git a/modules/dnn/include/opencv2/dnn/dnn.hpp b/modules/dnn/include/opencv2/dnn/dnn.hpp index 7da73d3cbf..7a5cb3824f 100644 --- a/modules/dnn/include/opencv2/dnn/dnn.hpp +++ b/modules/dnn/include/opencv2/dnn/dnn.hpp @@ -126,7 +126,6 @@ CV__DNN_INLINE_NS_BEGIN DNN_MODEL_ONNX = 1, //!< ONNX model DNN_MODEL_TF = 2, //!< TF model DNN_MODEL_TFLITE = 3, //!< TFLite model - DNN_MODEL_CAFFE = 4, //!< Caffe model }; CV_EXPORTS std::string modelFormatToString(ModelFormat modelFormat); diff --git a/modules/dnn/perf/perf_net.cpp b/modules/dnn/perf/perf_net.cpp index 029958dc07..c80866d451 100644 --- a/modules/dnn/perf/perf_net.cpp +++ b/modules/dnn/perf/perf_net.cpp @@ -145,8 +145,7 @@ PERF_TEST_P_(DNNTestNetwork, SSD) { applyTestTag(CV_TEST_TAG_DEBUG_VERYLONG); - // The Caffe-SSD specific handling lives in the new engine importer only; - // the classic importer can no longer load this model. + // SSD_VGG16's specialized preprocessing is handled by the new engine importer only. auto engine_forced = static_cast<dnn::EngineType>( utils::getConfigurationParameterSizeT("OPENCV_FORCE_DNN_ENGINE", dnn::ENGINE_AUTO)); if (engine_forced == dnn::ENGINE_CLASSIC) @@ -190,9 +189,7 @@ PERF_TEST_P_(DNNTestNetwork, OpenPose_pose_mpi_faster_4_stages) if (backend == DNN_BACKEND_INFERENCE_ENGINE_NN_BUILDER_2019 && (target == DNN_TARGET_MYRIAD || target == DNN_TARGET_HDDL)) throw SkipTestException(""); - // The same .caffemodel but modified .prototxt // See https://github.com/CMU-Perceptual-Computing-Lab/openpose/blob/master/src/openpose/pose/poseParameters.cpp - // processNet("dnn/openpose_pose_mpi.caffemodel", "dnn/openpose_pose_mpi_faster_4_stages.prototxt", cv::Size(368, 368)); processNet("dnn/onnx/models/openpose_pose_mpi.onnx", "", cv::Size(368, 368)); } diff --git a/modules/dnn/src/net_impl2.cpp b/modules/dnn/src/net_impl2.cpp index 4bd024f2d9..d497352dd1 100644 --- a/modules/dnn/src/net_impl2.cpp +++ b/modules/dnn/src/net_impl2.cpp @@ -263,8 +263,7 @@ std::string modelFormatToString(ModelFormat modelFormat) return modelFormat == DNN_MODEL_ONNX ? "ONNX" : modelFormat == DNN_MODEL_TF ? "TF" : - modelFormat == DNN_MODEL_TFLITE ? "TFLite" : - modelFormat == DNN_MODEL_CAFFE ? "Caffe" : "Unknown/Generic"; + modelFormat == DNN_MODEL_TFLITE ? "TFLite" : "Unknown/Generic"; } std::string argKindToString(ArgKind kind) diff --git a/modules/java/jar/CMakeLists.txt b/modules/java/jar/CMakeLists.txt index 70e0d47140..c755521e2b 100644 --- a/modules/java/jar/CMakeLists.txt +++ b/modules/java/jar/CMakeLists.txt @@ -87,15 +87,7 @@ install(FILES ${OPENCV_JAR_FILE} OPTIONAL DESTINATION ${OPENCV_JAR_INSTALL_PATH} add_dependencies(${the_module} ${the_module}_jar) -# Javadoc generation can be disabled independently of BUILD_DOCS so the C++ -# Doxygen / Sphinx docs still build when the Java bindings carry Doxygen-style -# tags (e.g. @retval, @remarks) that JDK doclint rejects as fatal errors. -# Default ON for parity with upstream; when OFF the `doxygen_javadoc` target is -# never created and doc/CMakeLists.txt's `if(TARGET doxygen_javadoc)` guard -# drops it from the doxygen_cpp dependency chain automatically. -option(BUILD_JAVADOC "Generate Javadoc as part of the documentation build" ON) - -if(BUILD_DOCS AND BUILD_JAVADOC) +if(BUILD_DOCS) if(OPENCV_JAVA_SDK_BUILD_TYPE STREQUAL "ANT") add_custom_command(OUTPUT "${OPENCV_DEPHELPER}/${the_module}doc" COMMAND ${ANT_EXECUTABLE} -noinput -k javadoc @@ -142,4 +134,4 @@ if(BUILD_DOCS AND BUILD_JAVADOC) add_dependencies(opencv_docs ${the_module}doc) else() unset(CMAKE_DOXYGEN_JAVADOC_NODE CACHE) -endif() +endif() \ No newline at end of file diff --git a/samples/dnn/README.md b/samples/dnn/README.md index c99b735a1f..60504ae710 100644 --- a/samples/dnn/README.md +++ b/samples/dnn/README.md @@ -10,7 +10,7 @@ There are different preprocessing parameters such mean subtraction or scale fact You may check the most popular models and their parameters at [models.yml](https://github.com/opencv/opencv/blob/5.x/samples/dnn/models.yml) configuration file. It might be also used for aliasing samples parameters. In example, ```bash -python object_detection.py opencv_fd --model /path/to/caffemodel --config /path/to/prototxt +python object_detection.py opencv_fd --model /path/to/model.onnx ``` Check `-h` option to know which values are used by default: @@ -34,14 +34,14 @@ You also can use the script to download necessary files from your code. Assume y ```python from download_models import downloadFile -filepath1 = downloadFile("https://drive.google.com/uc?export=download&id=0B3gersZ2cHIxRm5PMWRoTkdHdHc", None, filename="MobileNetSSD_deploy.caffemodel", save_dir="save_dir_1") -filepath2 = downloadFile("https://drive.google.com/uc?export=download&id=0B3gersZ2cHIxRm5PMWRoTkdHdHc", "994d30a8afaa9e754d17d2373b2d62a7dfbaaf7a", filename="MobileNetSSD_deploy.caffemodel") +filepath1 = downloadFile("https://huggingface.co/onnxmodelzoo/ssd_mobilenet_v1_12/resolve/main/ssd_mobilenet_v1_12.onnx", None, filename="ssd_mobilenet_v1_12.onnx", save_dir="save_dir_1") +filepath2 = downloadFile("https://huggingface.co/onnxmodelzoo/ssd_mobilenet_v1_12/resolve/main/ssd_mobilenet_v1_12.onnx", "83536889adce1eda154175f8e3b156dd20443631", filename="ssd_mobilenet_v1_12.onnx") print(filepath1) print(filepath2) # Your code ``` -By running the following commands, you will get **MobileNetSSD_deploy.caffemodel** file: +By running the following commands, you will get **ssd_mobilenet_v1_12.onnx** file: ```bash export OPENCV_DOWNLOAD_DATA_PATH=download_folder python your_script.py diff --git a/samples/dnn/common.hpp b/samples/dnn/common.hpp index 4e07a74778..345946a2fa 100644 --- a/samples/dnn/common.hpp +++ b/samples/dnn/common.hpp @@ -165,10 +165,10 @@ std::string genPreprocArguments(const std::string& modelName, const std::string& { return genArgument(prefix + "model", "Path to a binary file of model contains trained weights. " "It could be a file with extensions .caffemodel (Caffe), " - ".pb (TensorFlow), .weights (Darknet), .bin (OpenVINO).", + ".pb (TensorFlow), .bin (OpenVINO).", modelName, zooFile, 'm') + genArgument(prefix + "config", "Path to a text file of model contains network configuration. " - "It could be a file with extensions .prototxt (Caffe), .pbtxt (TensorFlow), .cfg (Darknet), .xml (OpenVINO).", + "It could be a file with extensions .prototxt (Caffe), .pbtxt (TensorFlow), .xml (OpenVINO).", modelName, zooFile, 'c') + genArgument(prefix + "mean", "Preprocess input image by subtracting mean values. Mean values should be in BGR order and delimited by spaces.", modelName, zooFile) + diff --git a/samples/dnn/common.py b/samples/dnn/common.py index acd7a56087..8c0b025f6d 100644 --- a/samples/dnn/common.py +++ b/samples/dnn/common.py @@ -76,10 +76,10 @@ def add_preproc_args(zoo, parser, sample, alias=None, prefix=""): add_argument(zoo, parser, prefix+'model', help='Path to a binary file of model contains trained weights. ' 'It could be a file with extensions .caffemodel (Caffe), ' - '.pb (TensorFlow), .weights (Darknet), .bin (OpenVINO)', alias=alias) + '.pb (TensorFlow), .bin (OpenVINO)', alias=alias) add_argument(zoo, parser, prefix+'config', help='Path to a text file of model contains network configuration. ' - 'It could be a file with extensions .prototxt (Caffe), .pbtxt or .config (TensorFlow), .cfg (Darknet), .xml (OpenVINO)', alias=alias) + 'It could be a file with extensions .prototxt (Caffe), .pbtxt or .config (TensorFlow), .xml (OpenVINO)', alias=alias) add_argument(zoo, parser, prefix+'mean', nargs='+', type=float, default=[0, 0, 0], help='Preprocess input image by subtracting mean values. ' 'Mean values should be in BGR order.', alias=alias) diff --git a/samples/dnn/models.yml b/samples/dnn/models.yml index 071b610561..76100a41b5 100644 --- a/samples/dnn/models.yml +++ b/samples/dnn/models.yml @@ -93,83 +93,46 @@ yolov5l: postprocessing: "yolov5" sample: "object_detection" -# YOLO4 object detection family from Darknet (https://github.com/AlexeyAB/darknet) -# YOLO object detection family from Darknet (https://pjreddie.com/darknet/yolo/) -# Might be used for all YOLOv2, TinyYolov2, YOLOv3, YOLOv4 and TinyYolov4 yolov4: load_info: - url: "https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v3_optimal/yolov4.weights" - sha1: "0143deb6c46fcc7f74dd35bf3c14edc3784e99ee" - model: "yolov4.weights" - config_load_info: - url: "https://github.com/opencv/opencv_extra/raw/refs/heads/4.x/testdata/dnn/yolov4.cfg" - sha1: "ed0aeace88527af7524c3baf66ca44fbf049b878" - config: "yolov4.cfg" + url: "https://huggingface.co/opencv/opencv_contribution/resolve/main/yolov4/yolov4.onnx" + sha1: "68df7133bef095d79531ad62d79295d82614de3b" + model: "yolov4.onnx" mean: [0, 0, 0] scale: 0.00392 - width: 416 - height: 416 + width: 608 + height: 608 rgb: true labels: "object_detection_classes_yolo.txt" - background_label_id: 0 - postprocessing: "darknet" + postprocessing: "yolov4" sample: "object_detection" yolov4-tiny: load_info: - url: "https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v4_pre/yolov4-tiny.weights" - sha1: "451caaab22fb9831aa1a5ee9b5ba74a35ffa5dcb" - model: "yolov4-tiny.weights" - config_load_info: - url: "https://github.com/opencv/opencv_extra/raw/refs/heads/4.x/testdata/dnn/yolov4-tiny-2020-12.cfg" - sha1: "b161c2b0984b0c3b466c04b0d6cb3e52f06d93dd" - config: "yolov4-tiny-2020-12.cfg" + url: "https://huggingface.co/opencv/opencv_contribution/resolve/main/yolov4/yolov4-tiny.onnx" + sha1: "158a74e9c6da57f5e4161c5dfc1ab592f47d958a" + model: "yolov4-tiny.onnx" mean: [0, 0, 0] scale: 0.00392 width: 416 height: 416 rgb: true labels: "object_detection_classes_yolo.txt" - background_label_id: 0 - postprocessing: "darknet" + postprocessing: "yolov4" sample: "object_detection" yolov3: load_info: - url: "https://pjreddie.com/media/files/yolov3.weights" - sha1: "520878f12e97cf820529daea502acca380f1cb8e" - model: "yolov3.weights" - config_load_info: - url: "https://github.com/opencv/opencv_extra/raw/refs/heads/4.x/testdata/dnn/yolov3.cfg" - sha1: "caaf16a895b7bae3cd5c042199d1df0269f3dce6" - config: "yolov3.cfg" + url: "https://huggingface.co/qualcomm/Yolo-v3/resolve/226ada6de9dcb32eebad7f74bf526714e2af6136/Yolo-v3.onnx" + sha1: "c37641ddf05cfe133efd4b66832f269d95f523cf" + model: "yolov3.onnx" mean: [0, 0, 0] scale: 0.00392 - width: 416 - height: 416 + width: 640 + height: 640 rgb: true labels: "object_detection_classes_yolo.txt" - background_label_id: 0 - postprocessing: "darknet" - sample: "object_detection" - -tiny-yolo-voc: - load_info: - url: "https://pjreddie.com/media/files/yolov2-tiny-voc.weights" - sha1: "24b4bd049fc4fa5f5e95f684a8967e65c625dff9" - model: "tiny-yolo-voc.weights" - config_load_info: - url: "https://github.com/opencv/opencv_extra/raw/refs/heads/4.x/testdata/dnn/tiny-yolo-voc.cfg" - sha1: "d26e2408ce4e20136278411760ba904d744fe5b5" - config: "tiny-yolo-voc.cfg" - mean: [0, 0, 0] - scale: 0.00392 - width: 416 - height: 416 - rgb: true - labels: "object_detection_classes_pascal_voc.txt" - background_label_id: 0 - postprocessing: "darknet" + postprocessing: "yolov4" sample: "object_detection" # Caffe implementation of SSD model from https://github.com/PINTO0309/MobileNet-SSD-RealSense diff --git a/samples/dnn/object_detection.cpp b/samples/dnn/object_detection.cpp index 683bd1e028..9770ccc2e5 100644 --- a/samples/dnn/object_detection.cpp +++ b/samples/dnn/object_detection.cpp @@ -77,7 +77,7 @@ string modelName, framework; static void preprocess(const Mat& frame, Net& net, Size inpSize); -static void postprocess(Mat& frame, const vector<Mat>& outs, Net& net, int backend, vector<int>& classIds, vector<float>& confidences, vector<Rect>& boxes, const string postprocessing); +static void postprocess(Mat& frame, const vector<Mat>& outs, Net& net, vector<int>& classIds, vector<float>& confidences, vector<Rect>& boxes, const string postprocessing); static void drawPred(vector<int>& classIds, vector<float>& confidences, vector<Rect>& boxes, Mat& frame, FontFace& sans, int stdSize, int stdWeight, int stdImgSize, int stdThickness); @@ -334,7 +334,7 @@ int main(int argc, char** argv) classIds.clear(); confidences.clear(); boxes.clear(); - postprocess(frame, outs, net, backend, classIds, confidences, boxes, postprocessing); + postprocess(frame, outs, net, classIds, confidences, boxes, postprocessing); drawPred(classIds, confidences, boxes, frame, sans, stdSize, stdWeight, stdImgSize, stdThickness); @@ -383,7 +383,7 @@ int main(int argc, char** argv) confidences.clear(); boxes.clear(); - postprocess(frame, outs, net, backend, classIds, confidences, boxes, postprocessing); + postprocess(frame, outs, net, classIds, confidences, boxes, postprocessing); drawPred(classIds, confidences, boxes, frame, sans, stdSize, stdWeight, stdImgSize, stdThickness); @@ -404,10 +404,7 @@ void preprocess(const Mat& frame, Net& net, Size inpSize) // Prepare the blob from the image Mat inp; - if(framework == "weights"){ // checks whether model is darknet - blobFromImage(frame, inp, scale, size, meanv, swapRB, false, CV_32F); - } - else{ + { //![preprocess_call] Image2BlobParams imgParams( Scalar::all(scale), @@ -513,7 +510,7 @@ void yoloPostProcessing( } } -void postprocess(Mat& frame, const vector<Mat>& outs, Net& net, int backend, vector<int>& classIds, vector<float>& confidences, vector<Rect>& boxes, const string postprocessing) +void postprocess(Mat& frame, const vector<Mat>& outs, Net& net, vector<int>& classIds, vector<float>& confidences, vector<Rect>& boxes, const string postprocessing) { static vector<int> outLayers = net.getUnconnectedOutLayers(); if (postprocessing == "ssd") @@ -552,35 +549,66 @@ void postprocess(Mat& frame, const vector<Mat>& outs, Net& net, int backend, vec } } } - else if (postprocessing == "darknet") + else if (postprocessing == "yolov4") { - for (size_t i = 0; i < outs.size(); ++i) + // boxes[b,N,1,4]+confs[b,N,classes] (normalized) or boxes[b,N,4]+scores[b,N]+classIdx[b,N] (model-px) + bool isBoxConfsFormat = (outs.size() == 2 && outs[0].dims == 4 && outs[0].size[outs[0].dims - 1] == 4); + bool isBoxScoresIdxFormat = (outs.size() == 3 && outs[0].dims == 3 && outs[0].size[2] == 4); + if (isBoxScoresIdxFormat) { - // Network produces output blob with a shape NxC where N is a number of - // detected objects and C is a number of classes + 4 where the first 4 - // numbers are [center_x, center_y, width, height] - float* data = (float*)outs[i].data; - for (int j = 0; j < outs[i].rows; ++j, data += outs[i].cols) + int N = outs[0].size[1]; + const float* boxesPtr = outs[0].ptr<float>(0); + const float* scoresPtr = outs[1].ptr<float>(0); + const float* classIdxPtr = outs[2].ptr<float>(0); + for (int j = 0; j < N; ++j) { - Mat scores = outs[i].row(j).colRange(5, outs[i].cols); - Point classIdPoint; - double confidence; - minMaxLoc(scores, 0, &confidence, 0, &classIdPoint); - if (confidence > confThreshold) + float score = scoresPtr[j]; + if (score > confThreshold) { - int centerX = (int)(data[0] * frame.cols); - int centerY = (int)(data[1] * frame.rows); - int width = (int)(data[2] * frame.cols); - int height = (int)(data[3] * frame.rows); - int left = centerX - width / 2; - int top = centerY - height / 2; - - classIds.push_back(classIdPoint.x); - confidences.push_back((float)confidence); - boxes.push_back(Rect(left, top, width, height)); + float x1 = boxesPtr[j * 4 + 0]; + float y1 = boxesPtr[j * 4 + 1]; + float x2 = boxesPtr[j * 4 + 2]; + float y2 = boxesPtr[j * 4 + 3]; + boxes.push_back(Rect((int)x1, (int)y1, (int)(x2 - x1), (int)(y2 - y1))); + confidences.push_back(score); + classIds.push_back((int)classIdxPtr[j]); } } } + else if (isBoxConfsFormat) + { + Mat boxesMat = outs[0]; + Mat confsMat = outs[1]; + int numBoxes = (int)(boxesMat.total() / 4); + boxesMat = boxesMat.reshape(1, numBoxes); + confsMat = confsMat.reshape(1, numBoxes); + for (int j = 0; j < numBoxes; ++j) + { + Point maxLoc; + double confidence; + minMaxLoc(confsMat.row(j), 0, &confidence, 0, &maxLoc); + if (confidence > confThreshold) + { + const float* box = boxesMat.ptr<float>(j); + boxes.push_back(Rect((int)(box[0] * inpWidth), (int)(box[1] * inpHeight), + (int)((box[2] - box[0]) * inpWidth), (int)((box[3] - box[1]) * inpHeight))); + confidences.push_back((float)confidence); + classIds.push_back(maxLoc.x); + } + } + } + else + { + cout << "Unsupported YOLO ONNX output format" << endl; + exit(-1); + } + Image2BlobParams paramNet; + paramNet.scalefactor = Scalar::all(scale); + paramNet.size = Size(inpWidth, inpHeight); + paramNet.mean = meanv; + paramNet.swapRB = swapRB; + paramNet.paddingmode = paddingMode; + paramNet.blobRectsToImageRects(boxes, boxes, frame.size()); } else if (postprocessing == "yolov8" || postprocessing == "yolov5") { @@ -620,7 +648,7 @@ void postprocess(Mat& frame, const vector<Mat>& outs, Net& net, int backend, vec // NMS is used inside Region layer only on DNN_BACKEND_OPENCV for other backends we need NMS in sample // or NMS is required if the number of outputs > 1 - if (outLayers.size() > 1 || (postprocessing == "darknet" && backend != DNN_BACKEND_OPENCV)) + if (outLayers.size() > 1) { map<int, vector<size_t> > class2indices; for (size_t i = 0; i < classIds.size(); i++) diff --git a/samples/dnn/object_detection.py b/samples/dnn/object_detection.py index 4388205ed5..a2509ea303 100644 --- a/samples/dnn/object_detection.py +++ b/samples/dnn/object_detection.py @@ -158,27 +158,44 @@ def postprocess(frame, outs): confidences.append(float(confidence)) boxes.append([left, top, width, height]) - elif args.postprocessing == 'darknet': - box_scale_w = frameWidth - box_scale_h = frameHeight - - for out in outs: - for detection in out: - scores = detection[4:] - if args.background_label_id >= 0: - scores = np.delete(scores, args.background_label_id) - classId = np.argmax(scores) - confidence = scores[classId] - if confidence > confThreshold: - center_x = int(detection[0] * box_scale_w) - center_y = int(detection[1] * box_scale_h) - width = int(detection[2] * box_scale_w) - height = int(detection[3] * box_scale_h) - left = int(center_x - width / 2) - top = int(center_y - height / 2) - classIds.append(classId) - confidences.append(float(confidence)) + elif args.postprocessing == 'yolov4': + # boxes[b,N,1,4]+confs[b,N,classes] (normalized) or boxes[b,N,4]+scores[b,N]+classIdx[b,N] (model-px) + if len(outs) == 3 and outs[0].ndim == 3 and outs[0].shape[2] == 4: + boxesArr = outs[0][0] + scoresArr = outs[1][0] + classIdxArr = outs[2][0] + for j in range(boxesArr.shape[0]): + score = float(scoresArr[j]) + if score > confThreshold: + x1 = boxesArr[j][0] / args.width + y1 = boxesArr[j][1] / args.height + x2 = boxesArr[j][2] / args.width + y2 = boxesArr[j][3] / args.height + left = int(x1 * frameWidth) + top = int(y1 * frameHeight) + width = int((x2 - x1) * frameWidth) + height = int((y2 - y1) * frameHeight) + classIds.append(int(classIdxArr[j])) + confidences.append(score) boxes.append([left, top, width, height]) + elif len(outs) == 2 and outs[0].ndim == 4 and outs[0].shape[-1] == 4: + boxesArr = outs[0].reshape(-1, 4) + confsArr = outs[1].reshape(boxesArr.shape[0], -1) + for j in range(boxesArr.shape[0]): + classId = np.argmax(confsArr[j]) + confidence = float(confsArr[j][classId]) + if confidence > confThreshold: + box = boxesArr[j] + left = int(box[0] * frameWidth) + top = int(box[1] * frameHeight) + width = int((box[2] - box[0]) * frameWidth) + height = int((box[3] - box[1]) * frameHeight) + classIds.append(classId) + confidences.append(confidence) + boxes.append([left, top, width, height]) + else: + print('Unsupported YOLO ONNX output format') + exit() elif args.postprocessing == 'yolov8' or args.postprocessing == 'yolov5': # Network produces output blob with a shape NxC where N is a number of @@ -219,7 +236,7 @@ def postprocess(frame, outs): # NMS is used inside Region layer only on DNN_BACKEND_OPENCV for another backends we need NMS in sample # or NMS is required if number of outputs > 1 - if len(outNames) > 1 or (args.postprocessing == 'darknet' or args.postprocessing == 'yolov8' or args.postprocessing == 'yolov5') and args.backend != cv.dnn.DNN_BACKEND_OPENCV: + if len(outNames) > 1 or (args.postprocessing == 'yolov8' or args.postprocessing == 'yolov5') and args.backend != cv.dnn.DNN_BACKEND_OPENCV: indices = [] classIds = np.array(classIds) boxes = np.array(boxes)