Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/02-design/2026-06-11-render-geometry-diagnostics-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,57 @@ TICK_CROWDING_WARN = 0.90 DATA_OUTSIDE_AXES_WARN = 0.01
LEGEND_OVERLAP_WARN = 0.05 COLORBAR_OVERLAP_WARN = 0.02
```

### 2.0.1 Raw v2 candidate-evidence metrics (additive)

The `geometry_diagnostics/2` raw measurement extension carries bounded
geometry facts without applying publication thresholds. It is additive to the
legacy `geometry_diagnostics/1` checks and does not rename or reinterpret
their `passed` values. Consumers must not infer a policy verdict from a
raw measurement's `availability`, counts, ratios, or reported pairs;
severity, outcome, and publication-readiness decisions exist only in the
selected policy projection. The raw `/2` envelope and its
`metric_id`/`availability`/`unit`/`scope` discriminator remain frozen; these
issue-specific pair fields are nested inside the existing `value` object and
do not add top-level measurement keys.

#### `artist_pair_iou`: axis-aligned candidate approximation

`artist_pair_iou` reports bounded candidate pairs and their compatibility-
named `iou` overlap fraction (intersection area divided by the smaller
candidate-box area). Each candidate is represented by an axis-aligned
display-space bounding box. The name and denominator remain unchanged for
the legacy `artist_overlaps` projection; this is not mathematical union IoU.
In particular, line candidates use padded axis-aligned boxes, so the value is
candidate evidence and not an exact painted-ink collision or line/text
crossing. The candidate set is assembled
per data axis from the neutral raw path and may include visible legends,
title/text artists, line segments, patches, and marker-footprint boxes.
Candidate and pair truncation flags are facts about bounded evaluation, not
quality outcomes. For a pair containing exactly one line segment, the raw
pair may additionally carry `centerline_intersection_px` and
`centerline_intersects`, computed by positive-length Liang--Barsky clipping
of the line centerline against the other candidate box. These fields are
absent for non-line and line-line pairs; a zero length exposes a diagonal-box
artifact without changing the legacy `iou` or its policy projection.

#### `line_text_crossings`: exact centerline/text fact

`line_text_crossings` reports an exact display-space intersection between
a line segment's centerline and a text bounding box. A crossing is counted only
when the centerline spends more than `GEOM_EPS_PX` of positive length
inside the text box; line width padding is not used for this metric. Its
explicit scope is one data axis at a time: visible, paintable
`Axes.get_lines()` (using each line's own transform, including blended
`axhline`/`axvline` transforms) against visible non-empty
`ax.title` and `ax.texts`. Tick labels, legends, patches, markers,
and other axes are out of scope. Line, segment, and text caps are reported in
the raw value when truncation occurs.

Both metrics are raw candidate evidence. They do not emit or imply
`pass`/`fail`, severity, warning status, or publication readiness;
the selected policy projection is the only layer that may assign those
meanings.

### 2.1 `tick_label_overlaps` (warning-eligible; FIXED for rotation round1-#1, adjacency round1-#37)
- **Definition:** count of *spatially* adjacent same-axis tick-label pairs that overlap. Reported separately for x and y. Lists colliding pairs as `[[i, j], …]`.
- **Compute:** `labels = [t for t in ax.get_xticklabels() if t.get_text() and t.get_visible()]`. Sort surviving labels by window-extent center **along the axis** (not by enumerate index — round1-#37: empty-label gaps must not make non-adjacent labels "adjacent"). For **rotated** labels (`t.get_rotation() % 180 != 0`, true for all bar plots), do NOT use the axis-aligned `get_window_extent` AABB (it is the fat diagonal strip, systematically false-positive — round1-#1). Instead test **along-baseline projected spacing**: gap between successive anchor centers minus the §2.0 axis-projected label length (`bb.width` for x, `bb.height` for y); overlap iff gap `< 0`. For unrotated labels use the standard overlap predicate (§2.0).
Expand Down
18 changes: 18 additions & 0 deletions docs/QA.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,24 @@ Diagnostic name mapping for current render outputs:
| `geometry_diagnostics/1` | `blank_area_ratio`, `label_offset_consistency` | `FQ-A4` advisory polish unless data visibility is impaired. |
| Metadata/caption surfaces | project figure metadata, axis titles, legend labels, callouts, captions | `FQ-A5` advisory narrative review; no current hard diagnostic name. |
| `geometry_diagnostics/1` | `legend_data_collision` | Informational only in the current implementation. |
| `geometry_diagnostics/2` raw facts | `artist_pair_iou` | Candidate evidence only: the compatibility-named `iou` overlap fraction over axis-aligned display-space artist boxes (not mathematical union IoU). Line/non-line pairs may add exact centerline-length metadata, but the raw metric still carries no policy verdict. |
| `geometry_diagnostics/2` raw facts | `line_text_crossings` | Candidate evidence only: exact positive-length display-space line-centerline versus text-bbox crossings within the explicitly bounded line/text scope. It does not provide a policy verdict. |

Raw geometry facts are evidence, not decisions. In particular, an
`artist_pair_iou` ratio is an axis-aligned bounding-box approximation and
may over-approximate the painted overlap. For a pair containing exactly one
line segment, `centerline_intersection_px` and `centerline_intersects` provide
additional centerline-versus-other-box evidence; they are absent for
non-line and line-line pairs and do not change the legacy ratio or its
projection. `line_text_crossings` is the separate exact
centerline-versus-text-bbox measurement for visible
`Axes.get_lines()` against visible non-empty title/text artists on the
same data axis. Neither raw metric includes a pass/fail, severity, or
publication-readiness conclusion. Consumers must apply the selected policy
projection (and preserve its unmeasured/availability state) before making a
quality or publication claim. The raw `/2` top-level and measurement fields
are frozen; issue-specific pair metadata is nested under an existing
measurement `value` object so legacy consumers can continue to ignore it.

---

Expand Down
21 changes: 20 additions & 1 deletion docs/specs/geometry-diagnostic-rubric-map.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
{
"schema_version": "geometry_diagnostic_rubric_map/1",
"source_schema_version": "geometry_diagnostics/1",
"description": "Machine-readable source of truth mapping every geometry_diagnostics/1 metric to the FigOps figure-quality rubric.",
"description": "Machine-readable source of truth mapping every geometry_diagnostics/1 metric to the FigOps figure-quality rubric. The additive raw_metric_semantics section documents geometry_diagnostics/2 candidate facts; raw facts do not carry policy verdicts.",
"raw_metric_semantics": {
"schema_version": "geometry_diagnostics/2",
"compatibility": "This section is documentation-only and additive. The geometry_diagnostics/1 metrics mapping and public legacy metric names remain unchanged; the frozen geometry_diagnostics/2 top-level and measurement discriminator fields remain unchanged. New pair facts stay nested under an existing measurement value object.",
"artist_pair_iou": {
"kind": "candidate_evidence",
"unit": "ratio",
"semantics": "Legacy `iou` overlap fraction (intersection area divided by the smaller candidate-box area) over bounded axis-aligned display-space artist boxes. The field name is retained for compatibility; it is an approximation, not mathematical union IoU or exact painted-ink/centerline collision. Line candidates use padded axis-aligned boxes.",
"scope": "Per data axis over the bounded candidates assembled by the neutral raw measurement path, including visible legends, title/text artists, line segments, patches, and marker-footprint boxes where available.",
"line_pair_metadata": "When exactly one pair member is a line segment, the pair may additionally carry `centerline_intersection_px` and `centerline_intersects`, measured against the other candidate box with positive-length Liang-Barsky clipping. These fields are absent for non-line and line-line pairs; a zero value identifies a bbox-only candidate contact without changing the legacy `iou`.",
"policy_verdict": "None. Pair counts and IoU values are raw facts; they do not imply pass, fail, severity, or publication readiness. A selected policy projection supplies any verdict."
},
"line_text_crossings": {
"kind": "candidate_evidence",
"unit": "structured",
"semantics": "Exact display-space centerline-versus-text-bounding-box intersection for a line segment with more than the geometry epsilon of positive length inside the text box. It does not use the padded line AABB used by artist_pair_iou.",
"scope": "Per data axis, visible paintable Axes.get_lines() (using each line's own transform, including blended axhline/axvline transforms) against visible non-empty ax.title and ax.texts only. Tick labels, legends, patches, markers, and other axes are out of scope; line, segment, and text candidates are bounded and truncation is reported.",
"policy_verdict": "None. Crossing counts and reported crossing facts are raw measurements only; they do not imply pass, fail, severity, or publication readiness. A selected policy projection supplies any verdict."
}
},
"metrics": {
"tick_label_overlaps": {
"rubric_id": "FQ-H3",
Expand Down
125 changes: 119 additions & 6 deletions hub_core/geometry_artist_overlaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

from __future__ import annotations

import re
from typing import TYPE_CHECKING, Any, Callable

import numpy as np
from matplotlib.transforms import Bbox

from .geometry_primitives import GEOM_EPS_PX, _box_area, _extent, _overlap_fraction, _overlap_severity

_LINE_SEGMENT_LABEL_PATTERN = re.compile(r"^line:(\d+)\[(\d+)\]$")

if TYPE_CHECKING:
from matplotlib.axes import Axes

Expand Down Expand Up @@ -80,25 +83,135 @@ def add_artist(artist: Any, fallback: str) -> None:
return candidates


def _line_overlap_boxes(ax: Axes, line: Any) -> list[Bbox]:
def _line_overlap_segments(
ax: Axes,
line: Any,
) -> list[tuple[Bbox, np.ndarray | None, np.ndarray | None]]:
"""Return a padded segment bbox and its centerline endpoints.

The bbox remains the legacy candidate geometry used by the IoU metric,
while the endpoints provide exact line-vs-artist evidence for any pair
that contains one line segment. Both are emitted in the same segment
order, so a ``line:i[j]`` label can be resolved back to its centerline.

This intentionally preserves the legacy finite-point handling and data
transform used by :func:`_line_overlap_boxes`; changing either would
silently change candidate labels or their bbox values for existing users.
A finite-point pair spanning a NaN path break retains its legacy box but
has unresolved exact endpoints, so callers cannot claim a collision
across the synthetic gap.
"""

xy = np.asarray(line.get_xydata(), dtype=float)
if xy.size == 0:
return []
finite = xy[np.all(np.isfinite(xy), axis=1)]
finite_mask = np.all(np.isfinite(xy[:, :2]), axis=1)
finite_indices = np.flatnonzero(finite_mask)
finite = xy[finite_mask, :2]
if len(finite) < 2:
return []
display = ax.transData.transform(finite)
if not np.all(np.isfinite(display)):
return []
try:
line_transform = line.get_transform()
except (AttributeError, RuntimeError):
line_transform = ax.transData
if line_transform is None or not hasattr(line_transform, "transform"):
line_transform = ax.transData
try:
exact_display = np.asarray(line_transform.transform(finite), dtype=float)
except (TypeError, ValueError, OverflowError, RuntimeError):
exact_display = display
exact_display_valid = exact_display.shape == display.shape and np.all(np.isfinite(exact_display))
half_width = max(GEOM_EPS_PX, float(line.get_linewidth()) / 2)
boxes: list[Bbox] = []
for start, end in zip(display, display[1:]):
segments: list[tuple[Bbox, np.ndarray | None, np.ndarray | None]] = []
for point_index, (start, end) in enumerate(zip(display, display[1:])):
x0 = min(float(start[0]), float(end[0])) - half_width
x1 = max(float(start[0]), float(end[0])) + half_width
y0 = min(float(start[1]), float(end[1])) - half_width
y1 = max(float(start[1]), float(end[1])) + half_width
boxes.append(Bbox.from_extents(x0, y0, x1, y1))
return boxes
if (
int(finite_indices[point_index + 1]) == int(finite_indices[point_index]) + 1
and exact_display_valid
):
exact_start: np.ndarray | None = np.asarray(exact_display[point_index], dtype=float)
exact_end: np.ndarray | None = np.asarray(exact_display[point_index + 1], dtype=float)
else:
exact_start = None
exact_end = None
segments.append((Bbox.from_extents(x0, y0, x1, y1), exact_start, exact_end))
return segments


def _line_overlap_boxes(ax: Axes, line: Any) -> list[Bbox]:
"""Return legacy padded AABBs for each finite line segment."""

return [box for box, _start, _end in _line_overlap_segments(ax, line)]


def _line_segment_resolver(ax: Axes) -> Callable[[str], tuple[np.ndarray, np.ndarray] | None]:
"""Resolve a ``line:i[j]`` candidate label to centerline endpoints.

The resolver is deliberately total: malformed labels and labels that no
longer resolve to a current line/segment return ``None`` rather than
raising during diagnostics. Segment lists are cached per line so a
dense candidate set does not recompute the same line for every pair; the
caller's reported-cap controls how many facts are emitted.
"""

cache: dict[int, list[tuple[Bbox, np.ndarray | None, np.ndarray | None]]] = {}
lines = list(ax.get_lines())

def resolve(label: str) -> tuple[np.ndarray, np.ndarray] | None:
if not isinstance(label, str):
return None
match = _LINE_SEGMENT_LABEL_PATTERN.match(label)
if match is None:
return None
line_index = int(match.group(1))
segment_index = int(match.group(2))
if not 0 <= line_index < len(lines):
return None
if line_index not in cache:
cache[line_index] = _line_overlap_segments(ax, lines[line_index])
segments = cache[line_index]
if not 0 <= segment_index < len(segments):
return None
_box, start, end = segments[segment_index]
if start is None or end is None:
return None
return start, end

return resolve


def _pair_centerline_intersection_px(
resolve: Callable[[str], tuple[np.ndarray, np.ndarray] | None],
label_a: str,
box_a: Bbox,
label_b: str,
box_b: Bbox,
) -> float | None:
"""Measure exact centerline length for a line/non-line candidate pair.

``None`` denotes pairs with no line or with two lines; those pairs retain
their legacy bbox IoU only. For exactly one line, the returned length is
the positive-length Liang--Barsky intersection with the other artist's
bbox. A zero value therefore exposes a diagonal-bbox artifact without
discarding the original bbox candidate data.
"""

segment_a = resolve(label_a)
segment_b = resolve(label_b)
if (segment_a is None) == (segment_b is None):
return None
if segment_a is not None:
start, end, other_box = segment_a[0], segment_a[1], box_b
else:
assert segment_b is not None
start, end, other_box = segment_b[0], segment_b[1], box_a
return float(_segment_bbox_intersection_length(start, end, other_box))


def _segment_bbox_intersection_length(
Expand Down
25 changes: 18 additions & 7 deletions hub_core/geometry_raw_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,16 @@ def threshold_neutral_geometry_measurements(
from .geometry_artist_overlaps import (
_artist_overlap_candidate_items,
_is_reportable_artist_overlap,
_line_segment_resolver,
_line_text_crossings,
_pair_centerline_intersection_px,
)
from .geometry_overlay_contrast import (
_artist_rgb,
_contrast_ratio,
_overlay_contrast_items,
)
from .geometry_primitives import _box_area, _boxes_overlap, _extent, _overlap_fraction
from .geometry_primitives import GEOM_EPS_PX, _box_area, _boxes_overlap, _extent, _overlap_fraction

if candidate_cap <= 0 or reported_cap <= 0:
raise ValueError("candidate_cap and reported_cap must be positive")
Expand Down Expand Up @@ -205,6 +207,7 @@ def threshold_neutral_geometry_measurements(
candidates = candidates[:candidate_cap]
pair_facts: list[dict[str, Any]] = []
pair_count = 0
resolve_segment = _line_segment_resolver(ax)
for index_a in range(len(candidates)):
label_a, box_a, artist_a = candidates[index_a]
for index_b in range(index_a + 1, len(candidates)):
Expand All @@ -215,13 +218,21 @@ def threshold_neutral_geometry_measurements(
continue
pair_count += 1
if len(pair_facts) < reported_cap:
pair_facts.append(
{
"a": label_a,
"b": label_b,
"iou": round(float(_overlap_fraction(box_a, box_b)), 6),
}
pair_fact: dict[str, Any] = {
"a": label_a,
"b": label_b,
"iou": round(float(_overlap_fraction(box_a, box_b)), 6),
}
# Retain the legacy bbox ratio, but expose exact
# centerline evidence for line/non-line pairs so a
# diagonal bbox artifact cannot be mistaken for contact.
centerline_px = _pair_centerline_intersection_px(
resolve_segment, label_a, box_a, label_b, box_b
)
if centerline_px is not None:
pair_fact["centerline_intersection_px"] = round(centerline_px, 6)
pair_fact["centerline_intersects"] = centerline_px > GEOM_EPS_PX
pair_facts.append(pair_fact)
measurements.append(
_available_measurement(
"artist_pair_iou",
Expand Down
Loading
Loading