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

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
This commit is contained in:
Abhishek Gola
2026-05-28 20:04:19 +05:30
committed by GitHub
parent 0908a2db6f
commit a1ad509753
5 changed files with 950 additions and 0 deletions
+2
View File
@@ -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"
)
+40
View File
@@ -0,0 +1,40 @@
# Sphinx wrapper for the OpenCV doc/ tree.
# Adds a `sphinx` custom target so `cmake --build <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 <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})")
+419
View File
@@ -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; }
+20
View File
@@ -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. #}
<nav>
<ul class="bd-navbar-elements navbar-nav">
{%- for link in (theme_external_links or []) %}
<li class="nav-item">
{# PyData's CSS adds the ↗ icon via .nav-external::after — don't
render a second one inline or it doubles up. #}
<a class="nav-link nav-external"
href="{{ link.url }}"
target="_blank"
rel="noopener noreferrer">{{ link.name }}</a>
</li>
{%- endfor %}
</ul>
</nav>
+469
View File
@@ -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 <build> --target sphinx
# output -> <build>/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 <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<title1>[^\n]+?)\s*\{#(?P<anchor1>[\w-]+)\}\s*\n[=\-]{3,}\s*$"
r"|"
r"^#+\s+(?P<title2>[^\n]+?)\s*\{#(?P<anchor2>[\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<title>[^\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}