From a1ad5097539d01668882397d0c29ed2f319c5a6b Mon Sep 17 00:00:00 2001 From: Abhishek Gola Date: Thu, 28 May 2026 20:04:19 +0530 Subject: [PATCH] Merge pull request #29091 from abhishek-gola:doc_optimizations New sphinx-documentation build #29091 ### Pull Request Readiness Checklist See details at https://github.com/opencv/opencv/wiki/How_to_contribute#making-a-good-pull-request - [x] I agree to contribute to the project under Apache 2 License. - [x] To the best of my knowledge, the proposed patch is not based on a code under GPL or another license that is incompatible with OpenCV - [x] The PR is proposed to the proper branch - [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 --- CMakeLists.txt | 2 + docs_sphinx/CMakeLists.txt | 40 +++ docs_sphinx/_static/custom.css | 419 ++++++++++++++++++++++ docs_sphinx/_templates/navbar-nav.html | 20 ++ docs_sphinx/conf.py | 469 +++++++++++++++++++++++++ 5 files changed, 950 insertions(+) create mode 100644 docs_sphinx/CMakeLists.txt create mode 100644 docs_sphinx/_static/custom.css create mode 100644 docs_sphinx/_templates/navbar-nav.html create mode 100644 docs_sphinx/conf.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d4f534561..b0be6bd514 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1040,6 +1040,7 @@ ocv_register_modules() # Generate targets for documentation add_subdirectory(doc) +add_subdirectory(docs_sphinx) # various data that is used by cv libraries and/or demo applications. # add_subdirectory(data) @@ -1310,6 +1311,7 @@ ocv_build_features_string(docs_status IF TARGET doxygen_cpp THEN "doxygen" IF TARGET doxygen_python THEN "python" IF TARGET doxygen_javadoc THEN "javadoc" + IF TARGET sphinx THEN "sphinx" IF BUILD_opencv_js OR DEFINED OPENCV_JS_LOCATION THEN "js" ELSE "NO" ) diff --git a/docs_sphinx/CMakeLists.txt b/docs_sphinx/CMakeLists.txt new file mode 100644 index 0000000000..46aed52ed1 --- /dev/null +++ b/docs_sphinx/CMakeLists.txt @@ -0,0 +1,40 @@ +# Sphinx wrapper for the OpenCV doc/ tree. +# Adds a `sphinx` custom target so `cmake --build --target sphinx` +# works the same way `--target doxygen` does. +# +# Gated on BUILD_DOCS so it follows the existing toggle. + +if(NOT BUILD_DOCS) + return() +endif() + +# Prefer the sphinx-build that lives in the conda env where myst-parser / +# pydata-sphinx-theme / breathe / exhale are installed. Override on the +# CMake command line: -DSPHINX_BUILD=/path/to/sphinx-build +find_program(SPHINX_BUILD + NAMES sphinx-build + DOC "Path to sphinx-build tool") + +if(NOT SPHINX_BUILD) + message(STATUS "docs_sphinx: sphinx-build not found; `sphinx` target disabled") + return() +endif() + +set(_SPHINX_SOURCE "${CMAKE_SOURCE_DIR}/doc") +set(_SPHINX_CONFDIR "${CMAKE_CURRENT_SOURCE_DIR}") +set(_SPHINX_OUTDIR "${CMAKE_CURRENT_BINARY_DIR}/html") + +add_custom_target(sphinx + COMMAND ${CMAKE_COMMAND} -E make_directory ${_SPHINX_OUTDIR} + COMMAND ${SPHINX_BUILD} -c ${_SPHINX_CONFDIR} ${_SPHINX_SOURCE} ${_SPHINX_OUTDIR} + WORKING_DIRECTORY ${_SPHINX_CONFDIR} + COMMENT "Building Sphinx HTML site -> ${_SPHINX_OUTDIR}" + VERBATIM) + +# Clean rebuild: `cmake --build --target sphinx-clean` +add_custom_target(sphinx-clean + COMMAND ${CMAKE_COMMAND} -E rm -rf ${_SPHINX_OUTDIR} + COMMENT "Removing ${_SPHINX_OUTDIR}" + VERBATIM) + +message(STATUS "docs_sphinx: sphinx target enabled (sphinx-build: ${SPHINX_BUILD})") diff --git a/docs_sphinx/_static/custom.css b/docs_sphinx/_static/custom.css new file mode 100644 index 0000000000..fe2d4ed5aa --- /dev/null +++ b/docs_sphinx/_static/custom.css @@ -0,0 +1,419 @@ +/* + * docs_sphinx/_static/custom.css + * Ported visual layer from the Prasadayus/opencv#27 Sphinx port, + * trimmed to the rules that apply to our minimal wrapper (PyData + * pydata-sphinx-theme + MyST, no doxysnippet/opencv_code_links/tabs + * extensions). Rules that target HTML markup we don't emit are + * dropped; rules that are pure pydata overrides are kept verbatim. + */ + +:root, +html[data-theme="light"], +html[data-theme="dark"] { + --pst-font-family-base: "Inter", "Source Sans Pro", -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, + sans-serif; + --pst-font-family-heading: var(--pst-font-family-base); + --pst-font-family-monospace: "JetBrains Mono", "SFMono-Regular", Menlo, + Consolas, "Liberation Mono", monospace; + --pst-font-size-base: 16px; +} + +html { font-size: 16px; } +body, .bd-main { font-family: var(--pst-font-family-base); } + +h1 { font-size: 2.25rem; font-weight: 700; line-height: 1.2; letter-spacing: -0.02em; } +h2 { font-size: 1.5rem; font-weight: 600; line-height: 1.3; letter-spacing: -0.01em; margin-top: 2rem; } +h3 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; } + +.bd-content p, .bd-content li { line-height: 1.6; } +.bd-content ol > li { margin-bottom: 1.1rem; padding-left: 0.25rem; } +.bd-content ol ol { list-style-type: lower-alpha; } +.bd-content ol ol ol { list-style-type: lower-roman; } +.bd-content ol > li > p:first-child > strong:first-child::after { content: ":"; } + +html[data-theme="light"] { + --pst-color-link: #0969da; + --pst-color-link-hover: #0550ae; + --opencv-accent: #003a6b; +} +html[data-theme="dark"] { + --opencv-accent: #a4c9ff; +} + +.bd-content a { text-decoration: none; color: #0969da; } +html[data-theme="dark"] .bd-article-container a { color: #539bf5 !important; } +.bd-content a:hover { + text-decoration: underline; + text-underline-offset: 0.22em; + text-decoration-thickness: 1px; + color: #0550ae; +} +html[data-theme="dark"] .bd-article-container a:hover { color: #6cb6ff !important; } +.bd-content a > code, .bd-content code > a { color: inherit; } + +/* --- Front-matter table (.opencv-meta-table — applied in source-read) -- */ +.opencv-meta-table, +.opencv-meta-table .pst-scrollable-table-container { margin: 0 !important; padding: 0 !important; } +.opencv-meta-table table { margin-bottom: 0 !important; } +.opencv-meta-table + p, +div.opencv-meta-table + p { margin-top: 1.5rem !important; } +.opencv-meta-table .pst-scrollable-table-container, +.opencv-meta-table table, +table.opencv-meta-table { + width: auto !important; + max-width: fit-content; + margin-right: auto; + margin-left: 0; + border: 1px solid var(--pst-color-border); + border-radius: 0.5rem; + border-collapse: separate !important; + border-spacing: 0 !important; + overflow: hidden; +} +.opencv-meta-table table th, +.opencv-meta-table table td, +table.opencv-meta-table th, +table.opencv-meta-table td { + padding: 0.45rem 1rem; + font-size: 0.92rem; + border-bottom: 1px solid var(--pst-color-border); + border-right: 1px solid var(--pst-color-border); + vertical-align: middle; + text-align: left !important; +} +.opencv-meta-table table td:first-child, +table.opencv-meta-table td:first-child { + background: rgba(0, 0, 0, 0.06); + color: var(--pst-color-on-surface); + font-weight: 600; + white-space: nowrap; +} +.opencv-meta-table table tr:last-child td, +table.opencv-meta-table tr:last-child td { border-bottom: none; } +.opencv-meta-table table td:last-child, +table.opencv-meta-table td:last-child { border-right: none; } +.opencv-meta-table table thead, +table.opencv-meta-table thead { display: none; } + +html[data-theme="dark"] .opencv-meta-table table, +html[data-theme="dark"] table.opencv-meta-table { + border: 1px solid #444c56 !important; + border-radius: 0.5rem !important; + overflow: hidden !important; + border-collapse: separate !important; + border-spacing: 0 !important; +} +html[data-theme="dark"] .opencv-meta-table table th, +html[data-theme="dark"] .opencv-meta-table table td, +html[data-theme="dark"] table.opencv-meta-table th, +html[data-theme="dark"] table.opencv-meta-table td { + border-bottom: 1px solid #444c56 !important; + border-right: 1px solid #444c56 !important; + color: #cdd9e5 !important; + background-color: #22272e !important; +} +html[data-theme="dark"] .opencv-meta-table table td:first-child, +html[data-theme="dark"] table.opencv-meta-table td:first-child { + background: #2d333b !important; + font-weight: 600 !important; + color: #cdd9e5 !important; +} +html[data-theme="dark"] .opencv-meta-table table tr:last-child td, +html[data-theme="dark"] table.opencv-meta-table tr:last-child td { border-bottom: none !important; } +html[data-theme="dark"] .opencv-meta-table table td:last-child, +html[data-theme="dark"] table.opencv-meta-table td:last-child { border-right: none !important; } + +/* --- Images & figures -------------------------------------------------- */ +.bd-content figure figcaption, +.bd-content figure figcaption p, +.bd-content figure figcaption .caption-text { + font-weight: 700 !important; + font-style: normal !important; +} +.bd-content figure, +.bd-content p img, +.bd-content li img { margin-top: 1rem; margin-bottom: 1rem; } +.bd-content figure { margin-left: auto; margin-right: auto; text-align: center; } +.bd-content p:has(> img:only-child) { text-align: center; } +.bd-content p > img { display: inline-block; } + +/* --- YouTube embeds (.opencv-youtube — emitted by @youtube{ID} rule) --- */ +.opencv-youtube { + position: relative; + width: 100%; + max-width: 720px; + margin: 1.5rem auto; + aspect-ratio: 16 / 9; + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12); +} +.opencv-youtube iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; +} + +/* --- Generic tables ---------------------------------------------------- */ +.bd-content table { + border-collapse: collapse !important; + width: 100%; + margin-bottom: 1rem; + border: 1px solid var(--pst-color-border) !important; +} +.bd-content table th { + border: 1px solid var(--pst-color-border) !important; + padding: 0.65rem 1rem !important; + background: var(--pst-color-surface) !important; + font-size: 0.8rem !important; + text-transform: uppercase !important; + letter-spacing: 0.07em !important; + color: var(--pst-color-muted) !important; + font-weight: 600 !important; +} +.bd-content table td { + border: 1px solid var(--pst-color-border) !important; + padding: 0.65rem 1rem !important; + vertical-align: middle !important; +} +.bd-content table tbody tr:hover td { background: var(--pst-color-surface) !important; } + +/* --- Header & navbar --------------------------------------------------- */ +.bd-header { + border-bottom: 1px solid var(--pst-color-border); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.bd-header .navbar { min-height: 3.25rem; padding-top: 0.25rem; padding-bottom: 0.25rem; } +.bd-header .navbar-header-items__start { gap: 1rem; align-items: center; } +.bd-header img.logo__image { max-height: 36px; } + +.version-badge { + display: inline-block; + padding: 0.1rem 0.5rem; + margin-left: 0.25rem; + font-size: 0.72rem; + font-weight: 600; + color: var(--pst-color-text-muted); + background: var(--pst-color-surface); + border: 1px solid var(--pst-color-border); + border-radius: 0.35rem; + line-height: 1.4; + white-space: nowrap; + letter-spacing: 0.04em; +} + +.bd-header .navbar-nav { gap: 0.25rem; } +.bd-header .navbar-nav > li > a { + font-size: 0.78rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--pst-color-text-muted); + padding: 0.4rem 0.75rem !important; + border-radius: 0.35rem; + transition: color 120ms ease, background 120ms ease; +} +.bd-header .navbar-nav > li > a:hover { + color: var(--opencv-accent); + background: var(--pst-color-surface); + text-decoration: none; +} +.bd-header .navbar-nav > li > a.active, +.bd-header .navbar-nav > li > a.current { + color: var(--opencv-accent); + font-weight: 600; +} +.bd-header .navbar-persistent--mobile, +.bd-header .navbar-persistent--container, +.bd-header button.theme-switch-button, +.bd-header .navbar-icon-links a.nav-link { color: var(--pst-color-text-muted); } +.bd-header button.theme-switch-button:hover, +.bd-header .navbar-icon-links a.nav-link:hover { color: var(--opencv-accent); } + +/* --- Wider reading column ---------------------------------------------- */ +@media (min-width: 960px) { + .bd-page-width, + .bd-container__inner.bd-page-width { max-width: none !important; } + .bd-container { max-width: none !important; } + .bd-sidebar-primary { padding-left: 1rem !important; padding-right: 1rem !important; } + .bd-main .bd-content, + .bd-main .bd-article-container { padding-left: 2rem; padding-right: 2rem; } + .bd-main .bd-article { padding-left: 0; padding-right: 0; } +} + +/* --- Left sidebar nav -------------------------------------------------- */ +.bd-sidebar-primary nav.bd-links { margin-right: 0 !important; } +.bd-sidebar-primary nav.bd-docs-nav p.bd-links__title { + font-size: 0.9rem !important; + font-weight: 700 !important; + text-transform: none !important; + letter-spacing: normal !important; + color: var(--pst-color-text-base) !important; + margin-bottom: 0.5rem !important; +} +.bd-sidebar-primary nav.bd-links li > a { + font-size: 0.9rem !important; + line-height: 1.45 !important; + padding-top: 0.28rem !important; + padding-bottom: 0.28rem !important; +} +.bd-sidebar-primary nav.bd-links .toctree-l1 > a { + font-weight: 500 !important; + padding-left: 0.65rem !important; +} +.bd-sidebar-primary nav.bd-links .toctree-l2 > a { + font-size: 0.875rem !important; + padding-left: 1.25rem !important; + color: var(--pst-color-text-muted) !important; +} +.bd-sidebar-primary nav.bd-links .toctree-l3 > a { + font-size: 0.86rem !important; + padding-left: 2rem !important; + color: var(--pst-color-text-muted) !important; +} +.bd-sidebar-primary nav.bd-links .current > a { + color: var(--pst-color-primary) !important; + font-weight: 600 !important; +} +html[data-theme="dark"] .bd-sidebar-primary nav.bd-links .current > a { color: #539bf5 !important; } + +/* --- Code blocks ------------------------------------------------------- */ +div.highlight { + position: relative; + display: block !important; + width: 100% !important; + background: transparent !important; + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +div.highlight pre { + font-size: 0.875rem !important; + line-height: 1.55 !important; + overflow-x: auto !important; + white-space: pre !important; + background: #f6f8fa !important; + color: #24292e !important; + border: none !important; + border-left: 3px solid #0550ae !important; + border-radius: 0.4rem !important; + padding: 0.6rem 0.85rem !important; + margin: 0 !important; + font-style: normal !important; +} +.highlight .c, .highlight .ch, .highlight .cm, +.highlight .cpf, .highlight .c1, .highlight .cs { font-style: normal !important; } + +html[data-theme="dark"] { color-scheme: dark; } +html[data-theme="dark"] div.highlight span, +html[data-theme="dark"] div.highlight * { color: unset !important; } + +/* Dark-mode Pygments tokens (matches the PR's mapping) */ +html[data-theme="dark"] .highlight .c, html[data-theme="dark"] .highlight .c1, +html[data-theme="dark"] .highlight .c2, html[data-theme="dark"] .highlight .cm, +html[data-theme="dark"] .highlight .cp, html[data-theme="dark"] .highlight .cs, +html[data-theme="dark"] .highlight .ch, +html[data-theme="dark"] .highlight .cpf { color: #e3b341 !important; font-style: normal !important; } +html[data-theme="dark"] .highlight .k, html[data-theme="dark"] .highlight .kd, +html[data-theme="dark"] .highlight .kn, html[data-theme="dark"] .highlight .kp, +html[data-theme="dark"] .highlight .kr, +html[data-theme="dark"] .highlight .kt { color: #f47067 !important; font-weight: 600; } +html[data-theme="dark"] .highlight .s, html[data-theme="dark"] .highlight .s1, +html[data-theme="dark"] .highlight .s2, html[data-theme="dark"] .highlight .sa, +html[data-theme="dark"] .highlight .sb, html[data-theme="dark"] .highlight .sc, +html[data-theme="dark"] .highlight .sd, html[data-theme="dark"] .highlight .se, +html[data-theme="dark"] .highlight .si, +html[data-theme="dark"] .highlight .ss { color: #96d0ff !important; } +html[data-theme="dark"] .highlight .n, html[data-theme="dark"] .highlight .na, +html[data-theme="dark"] .highlight .nb, html[data-theme="dark"] .highlight .nc, +html[data-theme="dark"] .highlight .nf, +html[data-theme="dark"] .highlight .nn { color: #cdd9e5 !important; } +html[data-theme="dark"] .highlight .o, +html[data-theme="dark"] .highlight .ow { color: #f47067 !important; } +html[data-theme="dark"] .highlight .mi, html[data-theme="dark"] .highlight .mf, +html[data-theme="dark"] .highlight .mh, +html[data-theme="dark"] .highlight .mo { color: #6cb6ff !important; } + +html[data-theme="dark"] div.highlight pre { + background: #2d333b !important; + color: #cdd9e5 !important; + border-left: 3px solid #539bf5 !important; +} +html[data-theme="dark"] code, +html[data-theme="dark"] .sig { + background-color: #2d333b !important; + color: #cdd9e5 !important; +} + +/* --- Heading anchors fade-in ------------------------------------------ */ +a.headerlink { opacity: 0; transition: opacity 120ms; } +h1:hover a.headerlink, h2:hover a.headerlink, h3:hover a.headerlink, +h4:hover a.headerlink, h5:hover a.headerlink, h6:hover a.headerlink, +dt:hover a.headerlink { opacity: 1; } + +/* --- Prev/next strip --------------------------------------------------- */ +.prev-next-area { overflow: hidden; clear: both; } +.prev-next-area .prev-next-title { + color: var(--pst-color-link) !important; + font-weight: 600 !important; + font-size: 1rem !important; +} +.prev-next-area a:hover .prev-next-title { + color: var(--pst-color-link-hover) !important; + text-decoration: underline !important; +} +.prev-next-area .prev-next-subtitle { + font-size: 0.78rem !important; + text-transform: uppercase !important; + letter-spacing: 0.05em !important; + color: var(--pst-color-muted) !important; +} + +/* --- Back-to-top pill (PyData's built-in #pst-back-to-top) ------------ */ +#pst-back-to-top { + font-family: var(--pst-font-family-base) !important; + font-size: 0.82rem !important; + font-weight: 600 !important; + letter-spacing: 0.05em !important; + text-transform: uppercase !important; + padding: 0.65rem 1.6rem !important; + border-radius: 2rem !important; + border: none !important; + background: linear-gradient(135deg, #0066cc, #003a6b) !important; + color: #fff !important; + box-shadow: 0 4px 14px rgba(0, 60, 120, 0.35) !important; + left: 50% !important; + transform: translateX(-50%) !important; + transition: box-shadow 150ms ease, opacity 150ms ease !important; +} +#pst-back-to-top:hover { + box-shadow: 0 6px 20px rgba(0, 60, 120, 0.5) !important; + color: #fff !important; + text-decoration: none !important; + opacity: 0.92 !important; +} + +/* --- C++ symbol signatures (Breathe-rendered API pages, when enabled) - */ +dl.cpp.function > dt.sig, +dl.cpp.class > dt.sig, +dl.cpp.struct > dt.sig, +dl.cpp.type > dt.sig { + background: var(--pst-color-surface); + border: 1px solid var(--pst-color-border); + border-left: 3px solid var(--opencv-accent); + border-radius: 0.4rem; + padding: 0.75rem 1rem; + font-size: 0.9rem; + line-height: 1.8; + overflow-x: auto; + margin-bottom: 0.5rem; +} +dl.cpp.function .sig-name.descname { color: var(--opencv-accent); font-weight: 700; } +dl.cpp.function .sig-prename.descclassname { color: var(--pst-color-text-muted); } + +/* --- MathJax inline alignment tweak ----------------------------------- */ +mjx-container.MathJax:not([display="true"]) { vertical-align: -0.15em !important; } diff --git a/docs_sphinx/_templates/navbar-nav.html b/docs_sphinx/_templates/navbar-nav.html new file mode 100644 index 0000000000..3d89bcc4b3 --- /dev/null +++ b/docs_sphinx/_templates/navbar-nav.html @@ -0,0 +1,20 @@ +{# Override of pydata_sphinx_theme's components/navbar-nav.html. + PyData's default renders both top-level toctree items AND the + configured external_links in one bar. For OpenCV we want ONLY the + Doxygen-style external nav (Main Page, Related Pages, Namespaces, + …) in the header — the tutorial module tree belongs in the left + sidebar, not the header. So we render external_links only. #} + diff --git a/docs_sphinx/conf.py b/docs_sphinx/conf.py new file mode 100644 index 0000000000..cf15e6d05d --- /dev/null +++ b/docs_sphinx/conf.py @@ -0,0 +1,469 @@ +"""Sphinx wrapper for opencv/doc/. + +The wrapper lives in opencv/docs_sphinx/ as a single conf.py. Sphinx is +invoked with config-dir / source-dir separation so the wrapper never +duplicates the legacy tree. Build via the CMake `sphinx` target: + + cmake --build --target sphinx + # output -> /docs_sphinx/html/ + +opencv/doc/ stays untouched: Doxygen-flavored directives in the .markdown +sources are translated to MyST in a `source-read` hook below. + +To enable additional tutorial modules, append their directory names (the +folder under opencv/doc/tutorials/) to DOC_MODULES below. The root index +(tutorials/tutorials.markdown) lists every module, but only modules in +DOC_MODULES are actually compiled — entries for the rest are dropped +from toctrees automatically. +""" + +from __future__ import annotations +import pathlib, re + +HERE = pathlib.Path(__file__).parent.resolve() +DOC_ROOT = (HERE.parent / "doc").resolve() +OPENCV_ROOT = HERE.parent.resolve() + +# --------------------------------------------------------------------------- +# SCOPE — add module folder names from opencv/doc/tutorials/ here. +# Override via env var to avoid editing this file: +# OPENCV_DOC_MODULES=photo,imgproc cmake --build --target sphinx +# --------------------------------------------------------------------------- +import os as _os +DOC_MODULES = [ + m.strip() + for m in (_os.environ.get("OPENCV_DOC_MODULES") or "photo,objdetect").split(",") + if m.strip() +] + +# -- Project ---------------------------------------------------------------- +project = "OpenCV" +author = "OpenCV Team" +release = "5.x" + +# -- Sphinx core ------------------------------------------------------------ +extensions = ["myst_parser"] +for _ext in ("sphinx_design", "sphinx_copybutton"): + try: + __import__(_ext) + extensions.append(_ext) + except ImportError: + pass +HAVE_SPHINX_DESIGN = "sphinx_design" in extensions + +source_suffix = {".md": "markdown", ".markdown": "markdown"} + +# Root tutorial index (lists all modules via @subpage). Stays the master +# regardless of how many modules are in DOC_MODULES. +master_doc = "tutorials/tutorials" + +# Source dir is opencv/doc/ — scope to the master + enabled modules only. +include_patterns = ["tutorials/tutorials.markdown"] + [ + f"tutorials/{m}/**" for m in DOC_MODULES +] +exclude_patterns = ["**/Thumbs.db", "**/.DS_Store"] + +myst_enable_extensions = [ + "colon_fence", "deflist", "dollarmath", "amsmath", + "attrs_inline", "attrs_block", "smartquotes", +] +myst_heading_anchors = 4 +suppress_warnings = ["myst.header", "myst.xref_missing", "toc.not_included"] + +# -- Doxygen integration ----------------------------------------------------- +# External links in the navbar and unbuilt-module sidebar entries point at +# the existing Doxygen build. Override the base URL or tagfile via env vars. +DOXYGEN_BASE_URL = ( + _os.environ.get("OPENCV_DOXYGEN_BASE_URL", "https://docs.opencv.org/5.x/") + .rstrip("/") + "/") +_TAG_FILE = pathlib.Path(_os.environ.get( + "OPENCV_DOXYGEN_TAGFILE", + str(HERE.parent.parent / "build" / "doc" / "doxygen" / "html" / "opencv.tag"), +)) + +# anchor -> doxygen URL filename (from opencv.tag if available). +_TAG_FILENAMES: dict[str, str] = {} +if _TAG_FILE.is_file(): + try: + import xml.etree.ElementTree as _ET + for _c in _ET.parse(str(_TAG_FILE)).getroot().iter("compound"): + if _c.get("kind") == "page": + _n, _f = _c.findtext("name"), _c.findtext("filename") + if _n and _f: + _TAG_FILENAMES[_n] = _f if _f.endswith(".html") else _f + ".html" + except Exception: + pass + +def _doxygen_url(page: str) -> str: + return DOXYGEN_BASE_URL + _TAG_FILENAMES.get(page, page) + +# -- HTML / PyData theme ---------------------------------------------------- +try: + import pydata_sphinx_theme # noqa: F401 + html_theme = "pydata_sphinx_theme" +except ImportError: + html_theme = "alabaster" + +html_title = "OpenCV Tutorials" +html_show_sourcelink = False +templates_path = ["_templates"] +html_static_path = ["_static"] +html_css_files = [ + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700" + "&family=JetBrains+Mono:wght@400;500&display=swap", + "custom.css", +] +html_theme_options = { + "logo": {"text": f"OpenCV {release}"}, + # Show all 7 Doxygen-style external links inline (no "More" dropdown). + "header_links_before_dropdown": 7, + # Doxygen-style top-level nav (the legacy site's MAIN PAGE / RELATED + # PAGES / NAMESPACES / CLASSES / FILES / EXAMPLES / JAVA DOCUMENTATION). + # All external — they target the existing Doxygen build. + "external_links": [ + {"url": _doxygen_url("index.html"), "name": "Main Page"}, + {"url": _doxygen_url("pages.html"), "name": "Related Pages"}, + {"url": _doxygen_url("namespaces.html"), "name": "Namespaces"}, + {"url": _doxygen_url("annotated.html"), "name": "Classes"}, + {"url": _doxygen_url("files.html"), "name": "Files"}, + {"url": _doxygen_url("examples.html"), "name": "Examples"}, + {"url": DOXYGEN_BASE_URL + "javadoc/", "name": "Java Documentation"}, + ], + "show_toc_level": 2, + "navigation_with_keys": True, + "show_prev_next": True, + "show_nav_level": 2, + "navigation_depth": 4, + "secondary_sidebar_items": ["page-toc"], + "back_to_top_button": True, + "show_version_warning_banner": False, + "icon_links": [{"name": "GitHub", + "url": "https://github.com/opencv/opencv", + "icon": "fa-brands fa-github"}], +} + +# =========================================================================== +# Doxygen-flavored .markdown -> MyST translation via source-read. +# Nothing on disk under opencv/doc/ is modified. +# =========================================================================== + +# Build anchor maps. Two kinds: +# _ANCHOR_TO_DOC anchor -> docname (internal — for enabled modules) +# _ANCHOR_TO_EXTERNAL anchor -> (title, url) (external — for the rest) +# Disabled modules still appear in the master toctree as external links to +# the Doxygen build, so the left sidebar shows the full module list. +_ANCHOR_TO_DOC: dict[str, str] = {} +_ANCHOR_TO_EXTERNAL: dict[str, tuple[str, str]] = {} + +_HEAD_RE = re.compile( + r"^(?P[^\n]+?)\s*\{#(?P[\w-]+)\}\s*\n[=\-]{3,}\s*$" + r"|" + r"^#+\s+(?P[^\n]+?)\s*\{#(?P[\w-]+)\}\s*$", + re.MULTILINE) + +def _scan_internal(path: pathlib.Path) -> None: + """Add every {#anchor} in `path` (file or dir) to _ANCHOR_TO_DOC.""" + files = [path] if (path.is_file() and path.suffix == ".markdown") \ + else (list(path.rglob("*.markdown")) if path.is_dir() else []) + for md in files: + try: + head = md.read_text(encoding="utf-8", errors="replace")[:4000] + except OSError: + continue + rel = md.relative_to(DOC_ROOT).with_suffix("").as_posix() + for m in re.finditer(r"\{#([\w-]+)\}", head): + _ANCHOR_TO_DOC[m.group(1)] = rel + +def _scan_external(toc_file: pathlib.Path) -> None: + """Pull the top heading's (title, anchor) from a module's table_of_content + file and add it to _ANCHOR_TO_EXTERNAL with a URL into the Doxygen build.""" + try: + head = toc_file.read_text(encoding="utf-8", errors="replace")[:4000] + except OSError: + return + m = _HEAD_RE.search(head) + if not m: + return + anchor = m.group("anchor1") or m.group("anchor2") + title = (m.group("title1") or m.group("title2") or "").strip() + if not anchor: + return + url = DOXYGEN_BASE_URL + _TAG_FILENAMES.get(anchor, "index.html") + _ANCHOR_TO_EXTERNAL[anchor] = (title, url) + +# Internal scan: master + every enabled module subtree. +_scan_internal(DOC_ROOT / "tutorials" / "tutorials.markdown") +for _m in DOC_MODULES: + _scan_internal(DOC_ROOT / "tutorials" / _m) + +# External scan: every OTHER module's top-level table_of_content_*.markdown. +for _toc in (DOC_ROOT / "tutorials").glob("*/table_of_content_*.markdown"): + if _toc.parent.name not in DOC_MODULES: + _scan_external(_toc) + +# Doxygen flattens IMAGE_PATH across every `images/` folder under the tutorial +# tree, so a tutorial can reference `images/foo.png` even when `foo.png` lives +# in a sibling module's `images/` directory. Mirror that behavior by building +# a basename -> doc-root-relative-path index once at import time. +_IMAGE_INDEX: dict[str, str] = {} +for _img in (DOC_ROOT / "tutorials").rglob("images/*"): + if _img.is_file(): + _IMAGE_INDEX.setdefault(_img.name, _img.relative_to(DOC_ROOT).as_posix()) + +_TOGGLE_LABELS = {"cpp": "C++", "java": "Java", "python": "Python"} + + +# Mirror of Doxygen's EXAMPLE_PATH (see opencv/doc/Doxyfile.in) — the bases a +# bare `@snippet some/path.cpp` is resolved against. OPENCV_ROOT comes first so +# fully-qualified paths like `samples/cpp/...` keep working. +_SNIPPET_BASES = [ + OPENCV_ROOT, + OPENCV_ROOT / "samples", + OPENCV_ROOT / "apps", +] + + +def _read_snippet(rel_path: str, label: str | None) -> tuple[str, str]: + """Return (code_text, language) for an @include / @snippet directive.""" + p = next((b / rel_path for b in _SNIPPET_BASES + if (b / rel_path).is_file()), None) + if p is None: + return f"// not found: {rel_path}\n", "text" + text = p.read_text(encoding="utf-8", errors="replace") + ext = p.suffix.lower() + lang = {".cpp": "cpp", ".hpp": "cpp", ".h": "cpp", ".c": "c", + ".py": "python", ".java": "java"}.get(ext, "text") + if label is None: + return text, lang + # Doxygen matches `[label]` after any comment-style marker (//, //!, #, ##) + # anywhere on a line — including labels wrapped in block-comments like + # `/* //! [label]` or `//! [label] */`. + pat = re.compile(r"^[^\[\n]*(?://!|//|##|#)[^\[\n]*\[" + re.escape(label) + + r"\][^\n]*$", re.MULTILINE) + matches = list(pat.finditer(text)) + if len(matches) < 2: + return f"// snippet not found: {rel_path} [{label}]\n", lang + body = text[matches[0].end():matches[1].start()].strip("\n") + lines = body.split("\n") + indents = [len(l) - len(l.lstrip(" ")) for l in lines if l.strip()] + if indents: + dedent = min(indents) + lines = [l[dedent:] if len(l) >= dedent else l for l in lines] + return "\n".join(lines), lang + + +def _emit_toggles(tabs: list[tuple[str, str]]) -> str: + if HAVE_SPHINX_DESIGN: + out = ["", "``````{tab-set}"] + for lang, body in tabs: + label = _TOGGLE_LABELS.get(lang, lang.title()) + out += [f"`````{{tab-item}} {label}", body, "`````"] + out += ["``````", ""] + return "\n".join(out) + # Fallback: render each toggle as a labeled section. + out = [""] + for lang, body in tabs: + label = _TOGGLE_LABELS.get(lang, lang.title()) + out += [f"**{label}**", "", body, ""] + return "\n".join(out) + + +def _translate(text: str, docname: str | None = None) -> str: + # 1. Heading anchors: "Title {#name}\n===" (setext) and "## Title {#name}" (ATX). + # Strip the anchor from the rendered heading and emit a MyST label + # "(name)=" immediately above. Setext converted to ATX for simplicity. + def _setext_repl(m: re.Match) -> str: + title = m.group("title").strip() + level = 1 if m.group("bar") == "=" else 2 + return f"({m.group('anchor')})=\n{'#' * level} {title}" + text = re.sub( + r"^(?P[^\n]+?)\s*\{#(?P<anchor>[\w-]+)\}\s*\n(?P<bar>[=\-])[=\-]{2,}\s*$", + _setext_repl, text, flags=re.MULTILINE) + text = re.sub( + r"^(?P<hashes>#+)\s+(?P<title>[^\n]+?)\s*\{#(?P<anchor>[\w-]+)\}\s*$", + lambda m: f"({m.group('anchor')})=\n{m.group('hashes')} {m.group('title')}", + text, flags=re.MULTILINE) + + # 2. Doxygen LaTeX math markers + text = re.sub(r"\\f\[(.+?)\\f\]", + lambda m: f"\n$$\n{m.group(1).strip()}\n$$\n", + text, flags=re.DOTALL) + text = re.sub(r"\\f\$(.+?)\\f\$", lambda m: f"${m.group(1)}$", + text, flags=re.DOTALL) + + # 3. @code{.lang} ... @endcode + def _code_repl(m: re.Match) -> str: + lang = (m.group("lang") or "").strip(".") or "text" + if lang == "none": + lang = "text" + return f"\n```{lang}\n{m.group('body').strip()}\n```\n" + text = re.sub( + r"@code(?:\{(?P<lang>[^}]*)\})?\s*\n(?P<body>.*?)\n\s*@endcode", + _code_repl, text, flags=re.DOTALL) + + # 4. @include path / @includelineno path + # (line numbering hint is dropped — MyST fenced blocks don't take :linenos: + # and PyData's code-block styling is already legible without it.) + def _include_repl(m: re.Match) -> str: + code, lang = _read_snippet(m.group("path"), None) + return f"\n```{lang}\n{code.rstrip()}\n```\n" + text = re.sub(r"@include(?:lineno)?\s+(?P<path>\S+)", _include_repl, text) + + # 5. @snippet path [Label] + def _snippet_repl(m: re.Match) -> str: + code, lang = _read_snippet(m.group("path"), m.group("label")) + return f"\n```{lang}\n{code.rstrip()}\n```\n" + text = re.sub(r"@snippet\s+(?P<path>\S+)\s+(?P<label>[^\n]+?)\s*$", + _snippet_repl, text, flags=re.MULTILINE) + + # 6. @add_toggle_LANG ... @end_toggle (coalesce runs into one tab-set) + def _toggle_collapse(src: str) -> str: + out, i = [], 0 + opener = re.compile(r"^\s*@add_toggle_(\w+)\s*$", re.MULTILINE) + while True: + m = opener.search(src, i) + if not m: + out.append(src[i:]); break + out.append(src[i:m.start()]) + tabs, j = [], m.start() + while True: + m2 = re.match( + r"\s*@add_toggle_(\w+)\s*\n(.*?)\n\s*@end_toggle\s*\n?", + src[j:], flags=re.DOTALL) + if not m2: + break + tabs.append((m2.group(1), m2.group(2))) + j += m2.end() + k = re.match(r"\s*", src[j:]) + if not k or not re.match(r"@add_toggle_", src[j + k.end():]): + break + j += k.end() + if not tabs: + out.append(src[m.start():m.start() + 1]); i = m.start() + 1; continue + out.append(_emit_toggles(tabs)) + i = j + return "".join(out) + text = _toggle_collapse(text) + + # 7. @ref name [optional "Display Text"] + def _ref_repl(m: re.Match) -> str: + name = m.group("name"); disp = m.group("disp") + target = _ANCHOR_TO_DOC.get(name) + if target: + return f"[{disp or name}]({'/' + target})" + return f"[{disp or name}](#{name})" + text = re.sub(r'@ref\s+(?P<name>[\w-]+)(?:\s+"(?P<disp>[^"]+)")?', + _ref_repl, text) + + # 8. @cite KEY -> [KEY] + text = re.sub(r"@cite\s+([\w-]+)", r"[\1]", text) + + # 8b. @youtube{ID} -> responsive embed (raw HTML, passed through by MyST). + text = re.sub( + r"^@youtube\{(?P<id>[\w-]+)\}\s*$", + lambda m: ( + '\n<div class="opencv-youtube">' + f'<iframe src="https://www.youtube-nocookie.com/embed/{m.group("id")}" ' + 'title="YouTube video player" frameborder="0" ' + 'allow="accelerometer; autoplay; clipboard-write; encrypted-media; ' + 'gyroscope; picture-in-picture" allowfullscreen></iframe></div>\n' + ), + text, flags=re.MULTILINE) + + # 8c. @note ... / @see ... -> MyST admonitions. Each directive body runs + # until the next blank line, the next @directive at start-of-line, or + # end of file (matches Doxygen's paragraph-level semantics). + _ADMON_KIND = {"note": "note", "see": "seealso"} + def _admon_repl(m: re.Match) -> str: + kind = _ADMON_KIND[m.group("dir")] + body = m.group("body").strip() + return f"\n:::{{{kind}}}\n{body}\n:::\n" + text = re.sub( + r"^@(?P<dir>note|see)\s+(?P<body>.+?)(?=\n[ \t]*\n|\n@[A-Za-z]|\Z)", + _admon_repl, text, flags=re.DOTALL | re.MULTILINE) + + # 9. @subpage NAME (collected blocks -> real toctree). + # Enabled modules' anchors become internal toctree entries. + # Disabled modules' anchors become external links into the Doxygen + # build, so the left sidebar still shows the full module list. + def _subpage_list_to_toctree(src: str) -> str: + pat = re.compile( + r"((?:^[ \t]*-\s+@subpage\s+[\w-]+(?:[^\n]*)\n)+)", re.MULTILINE) + def repl(m: re.Match) -> str: + entries = re.findall(r"@subpage\s+([\w-]+)", m.group(1)) + lines = [] + for e in entries: + if e in _ANCHOR_TO_DOC: + lines.append("/" + _ANCHOR_TO_DOC[e]) + elif e in _ANCHOR_TO_EXTERNAL: + title, url = _ANCHOR_TO_EXTERNAL[e] + lines.append(f"{title} <{url}>") + if not lines: + return "" + body = "\n".join(lines) + return f"\n```{{toctree}}\n:maxdepth: 1\n\n{body}\n```\n" + return pat.sub(repl, src) + text = _subpage_list_to_toctree(text) + + # 10. @next_tutorial / @prev_tutorial -> drop + text = re.sub(r"^@(?:next|prev)_tutorial\{[^}]*\}\s*$", "", + text, flags=re.MULTILINE) + + # 11. @tableofcontents -> drop (PyData right sidebar replaces it) + text = re.sub(r"^@tableofcontents\s*$", "", text, flags=re.MULTILINE) + + # 11b. @cond NAME ... @endcond -> strip just the markers; if the + # enclosed @subpage points to a disabled module it gets dropped + # by _subpage_list_to_toctree above. + text = re.sub(r"^@cond\s+\S+\s*$", "", text, flags=re.MULTILINE) + text = re.sub(r"^@endcond\s*$", "", text, flags=re.MULTILINE) + + # 12. Image paths "images/foo.png" — resolve like Doxygen's flat IMAGE_PATH: + # prefer the doc's own "images/" sibling, then fall back to a global + # basename lookup across every tutorial `images/` folder. As a final + # fallback, point at the consolidated `tutorials/others/images/` dir + # (where modules like `photo` store their assets). + def _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 m.group(0) + hit = _IMAGE_INDEX.get(pathlib.Path(rel).name) + if hit: + return f'{m.group("pre")}/{hit})' + return f'{m.group("pre")}/tutorials/others/images/{rel})' + text = re.sub( + r'(?P<pre>!\[[^\]]*\]\()images/(?P<rel>[^)]+)\)', + _img_repl, text) + + # 13. Front-matter table: OpenCV tutorials use the "| -: | :- |" + # alignment pattern for the Original-author/Compatibility block. + # Wrap it in a {div} carrying .opencv-meta-table so custom.css + # can pin the rounded card + label-column styling without us + # modifying the .markdown source. + def _wrap_front_matter(src: str) -> str: + pat = re.compile( + r"(^\|[^\n]*\|[ \t]*\n" # header row (often empty) + r"\|[ \t]*-:[ \t]*\|[ \t]*:-[ \t]*\|[ \t]*\n" # alignment row + r"(?:\|[^\n]*\|[ \t]*\n)+)", # one or more body rows + re.MULTILINE) + def repl(m: re.Match) -> str: + return f":::{{div}} opencv-meta-table\n\n{m.group(1)}\n:::\n" + return pat.sub(repl, src, count=1) + text = _wrap_front_matter(text) + + return text + + +def _source_read(app, docname, source): + # Translate any tutorial doc — the root index plus everything under a + # module we enabled in DOC_MODULES. + if not docname.startswith("tutorials/"): + return + source[0] = _translate(source[0], docname) + + +def setup(app): + app.connect("source-read", _source_read) + return {"parallel_read_safe": True, "parallel_write_safe": True}