From 8a7aa5ccd4bd0e48a901d3cc05c06cdea0d675e2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 3 Aug 2026 09:42:39 +0900 Subject: [PATCH] fix(geometry): expose exact line contact evidence --- ...-06-11-render-geometry-diagnostics-spec.md | 51 ++++ docs/QA.md | 18 ++ .../specs/geometry-diagnostic-rubric-map.json | 21 +- hub_core/geometry_artist_overlaps.py | 125 +++++++++- hub_core/geometry_raw_contract.py | 25 +- tests/test_issue234_geometry.py | 226 ++++++++++++++++++ 6 files changed, 452 insertions(+), 14 deletions(-) create mode 100644 tests/test_issue234_geometry.py diff --git a/docs/02-design/2026-06-11-render-geometry-diagnostics-spec.md b/docs/02-design/2026-06-11-render-geometry-diagnostics-spec.md index 64236c1..48953de 100644 --- a/docs/02-design/2026-06-11-render-geometry-diagnostics-spec.md +++ b/docs/02-design/2026-06-11-render-geometry-diagnostics-spec.md @@ -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). diff --git a/docs/QA.md b/docs/QA.md index 5aecb77..464c99d 100644 --- a/docs/QA.md +++ b/docs/QA.md @@ -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. --- diff --git a/docs/specs/geometry-diagnostic-rubric-map.json b/docs/specs/geometry-diagnostic-rubric-map.json index bf4d5ce..c64bd8c 100644 --- a/docs/specs/geometry-diagnostic-rubric-map.json +++ b/docs/specs/geometry-diagnostic-rubric-map.json @@ -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", diff --git a/hub_core/geometry_artist_overlaps.py b/hub_core/geometry_artist_overlaps.py index ea792d5..2bea482 100644 --- a/hub_core/geometry_artist_overlaps.py +++ b/hub_core/geometry_artist_overlaps.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any, Callable import numpy as np @@ -9,6 +10,8 @@ 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 @@ -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( diff --git a/hub_core/geometry_raw_contract.py b/hub_core/geometry_raw_contract.py index 4e7e2d6..89bfa60 100644 --- a/hub_core/geometry_raw_contract.py +++ b/hub_core/geometry_raw_contract.py @@ -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") @@ -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)): @@ -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", diff --git a/tests/test_issue234_geometry.py b/tests/test_issue234_geometry.py new file mode 100644 index 0000000..648f937 --- /dev/null +++ b/tests/test_issue234_geometry.py @@ -0,0 +1,226 @@ +import json +import unittest + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +from matplotlib.transforms import Bbox # noqa: E402 + +from hub_core.geometry_artist_overlaps import ( # noqa: E402 + _line_overlap_boxes, + _line_overlap_segments, + _line_segment_resolver, + _pair_centerline_intersection_px, +) +from hub_core.geometry_diagnostics import diagnose_figure_geometry # noqa: E402 + + +def _diagonal_figure(legend_loc: str): + """Build a diagonal whose padded AABB contains the legend.""" + + fig, ax = plt.subplots(figsize=(4, 3), dpi=100) + ax.plot([0, 3], [2, 0], "b-.", label="contact resistance") + ax.set_xlim(0, 3) + ax.set_ylim(0, 2) + ax.legend(loc=legend_loc) + fig.canvas.draw() + return fig, ax + + +class Issue234GeometryTests(unittest.TestCase): + def tearDown(self): + plt.close("all") + + def test_diagonal_bbox_iou_is_corrected_by_centerline_fact(self): + fig, ax = _diagonal_figure("lower left") + raw = diagnose_figure_geometry(fig, [ax], layout_locked=False, contract_version="raw") + measurement = next( + item for item in raw["measurements"] if item["metric_id"] == "artist_pair_iou[axis=0]" + ) + pairs = [ + pair + for pair in measurement["value"]["pairs"] + if "legend" in (pair["a"], pair["b"]) + and (pair["a"].startswith("line:") or pair["b"].startswith("line:")) + ] + self.assertTrue(pairs, "expected a legend/line pair from the diagonal's AABB") + for pair in pairs: + # Keep the legacy bbox candidate ratio for compatibility. + self.assertGreater(pair["iou"], 0.0) + # Exact centerline evidence distinguishes the bbox artifact. + self.assertEqual(pair["centerline_intersection_px"], 0.0) + self.assertFalse(pair["centerline_intersects"]) + self.assertNotIn("threshold", json.dumps(measurement)) + self.assertNotIn("passed", json.dumps(measurement)) + + def test_real_line_legend_collision_still_reports_positive_centerline(self): + fig, ax = _diagonal_figure("center") + raw = diagnose_figure_geometry(fig, [ax], layout_locked=False, contract_version="raw") + measurement = next( + item for item in raw["measurements"] if item["metric_id"] == "artist_pair_iou[axis=0]" + ) + pairs = [ + pair + for pair in measurement["value"]["pairs"] + if "legend" in (pair["a"], pair["b"]) + and (pair["a"].startswith("line:") or pair["b"].startswith("line:")) + ] + self.assertTrue(pairs) + self.assertTrue(any(pair["centerline_intersects"] for pair in pairs)) + + def test_legacy_artist_overlap_keeps_bbox_projection(self): + fig, ax = _diagonal_figure("lower left") + legacy = diagnose_figure_geometry(fig, [ax], layout_locked=False) + raw = diagnose_figure_geometry(fig, [ax], layout_locked=False, contract_version="raw") + + legacy_check = next(item for item in legacy["checks"] if item["name"] == "artist_overlaps") + legacy_pair = next( + pair + for pair in legacy_check["data"]["overlaps"] + if "legend" in (pair["a"], pair["b"]) + and (pair["a"].startswith("line:") or pair["b"].startswith("line:")) + ) + raw_measurement = next( + item for item in raw["measurements"] if item["metric_id"] == "artist_pair_iou[axis=0]" + ) + raw_pair = next( + pair + for pair in raw_measurement["value"]["pairs"] + if {pair["a"], pair["b"]} == {legacy_pair["a"], legacy_pair["b"]} + ) + + # The legacy projection intentionally keeps its AABB verdict and IoU; + # exact centerline facts are additive on the raw evidence surface. + self.assertFalse(legacy_check["passed"]) + self.assertEqual(legacy_pair["iou"], raw_pair["iou"]) + self.assertNotIn("centerline_intersects", legacy_pair) + self.assertFalse(raw_pair["centerline_intersects"]) + + def test_non_line_pairs_carry_no_centerline_field(self): + fig, ax = plt.subplots(figsize=(4, 3), dpi=100) + ax.text(0.5, 0.5, "alpha", ha="center", va="center") + ax.text(0.52, 0.5, "beta", ha="center", va="center") + fig.canvas.draw() + + raw = diagnose_figure_geometry(fig, [ax], layout_locked=False, contract_version="raw") + measurement = next( + item for item in raw["measurements"] if item["metric_id"] == "artist_pair_iou[axis=0]" + ) + for pair in measurement["value"]["pairs"]: + if pair["a"].startswith("line:") or pair["b"].startswith("line:"): + continue + self.assertNotIn("centerline_intersection_px", pair) + self.assertNotIn("centerline_intersects", pair) + + def test_segment_boxes_and_endpoints_share_one_index(self): + fig, ax = _diagonal_figure("lower left") + line = ax.get_lines()[0] + segments = _line_overlap_segments(ax, line) + boxes = _line_overlap_boxes(ax, line) + self.assertEqual(len(segments), len(boxes)) + for (box, start, end), legacy_box in zip(segments, boxes): + self.assertEqual(list(box.extents), list(legacy_box.extents)) + for point in (start, end): + self.assertGreaterEqual(float(point[0]), box.x0) + self.assertLessEqual(float(point[0]), box.x1) + self.assertGreaterEqual(float(point[1]), box.y0) + self.assertLessEqual(float(point[1]), box.y1) + + def test_resolver_rejects_unknown_and_out_of_range_labels(self): + fig, ax = _diagonal_figure("lower left") + resolve = _line_segment_resolver(ax) + self.assertIsNotNone(resolve("line:0[0]")) + self.assertIsNone(resolve("legend")) + self.assertIsNone(resolve("text:'x'")) + self.assertIsNone(resolve("line:9[0]")) + self.assertIsNone(resolve("line:0[999]")) + + def test_nan_gap_keeps_legacy_box_but_withholds_centerline_fact(self): + fig, ax = plt.subplots(figsize=(4, 3), dpi=100) + ax.plot([0.0, 1.0, np.nan, 2.0, 3.0], [0.0, 1.0, np.nan, 2.0, 3.0]) + ax.text(1.5, 1.5, "gap", ha="center", va="center") + fig.canvas.draw() + + # The historical finite-point filter still emits the synthetic + # cross-gap AABB (so candidate discovery remains compatible), but the + # exact resolver refuses to turn that gap into a real line segment. + segments = _line_overlap_segments(ax, ax.get_lines()[0]) + self.assertEqual(len(segments), 3) + self.assertIsNone(segments[1][1]) + self.assertIsNone(segments[1][2]) + resolve = _line_segment_resolver(ax) + self.assertIsNone(resolve("line:0[1]")) + gap_box = segments[1][0] + target_box = Bbox.from_extents(gap_box.x0, gap_box.y0, gap_box.x1, gap_box.y1) + self.assertIsNone( + _pair_centerline_intersection_px(resolve, "line:0[1]", gap_box, "text:'gap'", target_box) + ) + raw = diagnose_figure_geometry(fig, [ax], layout_locked=False, contract_version="raw") + measurement = next( + item for item in raw["measurements"] if item["metric_id"] == "artist_pair_iou[axis=0]" + ) + gap_pairs = [ + pair + for pair in measurement["value"]["pairs"] + if {pair["a"], pair["b"]} == {"text:'gap'", "line:0[1]"} + ] + self.assertEqual(len(gap_pairs), 1) + self.assertNotIn("centerline_intersection_px", gap_pairs[0]) + self.assertNotIn("centerline_intersects", gap_pairs[0]) + + def test_reference_line_centerline_uses_artist_transform(self): + for reference_kind in ("h", "v"): + with self.subTest(reference_kind=reference_kind): + fig, ax = plt.subplots(figsize=(4, 3), dpi=100) + ax.set_xlim(10, 20) + ax.set_ylim(100, 200) + if reference_kind == "h": + ax.axhline(150, linewidth=0.5) + else: + ax.axvline(15, linewidth=0.5) + text = ax.text(15, 150, "reference", ha="center", va="center") + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + line = ax.get_lines()[0] + segments = _line_overlap_segments(ax, line) + target_box = text.get_window_extent(renderer) + resolve = _line_segment_resolver(ax) + measured = _pair_centerline_intersection_px( + resolve, "line:0[0]", segments[0][0], "text:'reference'", target_box + ) + self.assertIsNotNone(measured) + self.assertGreater(measured, 0.0) + + def test_pair_with_two_line_segments_is_not_measured(self): + fig, ax = plt.subplots(figsize=(4, 3), dpi=100) + ax.plot([0, 1], [0, 1]) + ax.plot([0, 1], [1, 0]) + fig.canvas.draw() + resolve = _line_segment_resolver(ax) + box = Bbox.from_extents(0.0, 0.0, 1.0, 1.0) + self.assertIsNone( + _pair_centerline_intersection_px(resolve, "line:0[0]", box, "line:1[0]", box) + ) + + def test_centerline_length_matches_direct_geometry(self): + fig, ax = _diagonal_figure("center") + resolve = _line_segment_resolver(ax) + legend_box = ax.get_legend().get_window_extent(fig.canvas.get_renderer()) + segments = _line_overlap_segments(ax, ax.get_lines()[0]) + box, start, end = segments[0] + measured = _pair_centerline_intersection_px( + resolve, "legend", legend_box, "line:0[0]", box + ) + self.assertIsNotNone(measured) + mirrored = _pair_centerline_intersection_px( + resolve, "line:0[0]", box, "legend", legend_box + ) + self.assertAlmostEqual(measured, mirrored) + self.assertGreater(measured, 0.0) + self.assertLessEqual(measured, float(np.hypot(*(end - start)))) + + +if __name__ == "__main__": + unittest.main()