diff --git a/config/config.template.json b/config/config.template.json index 2ced4828..e2feede2 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -134,6 +134,7 @@ "intra_plugin_gap": 8, "render_width_pct": 100, "min_content_separation": 24, + "min_cut_gap": 6, "auto_trim": true, "trim_threshold": 10, "content_padding": 8, diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index efe1fc71..a4fe7801 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -64,6 +64,14 @@ class VegasModeConfig: # per cycle, so a 20-plugin install took seven cycles to come around. plugins_per_cycle: int = 6 + # Minimum run of blank columns that counts as a boundary between items when + # an oversized segment has to be narrowed. Measured on rendered text, the + # gaps between characters are a single column while gaps between items are + # 8px and up, so anything above 1 stops a cut landing inside a word. Cutting + # mid-word orphaned the tail into the next cycle, which showed up as a lone + # letter floating between two unrelated plugins. + min_cut_gap: int = 6 + # Cap on one plugin's share of a cycle, as a multiple of display width. # A single ticker returning 7,000px would otherwise hold the panel for over # two minutes. Overflow is deferred to later cycles rather than discarded. @@ -108,6 +116,7 @@ class VegasModeConfig: render_width_pct=int(vegas_config.get('render_width_pct', 100)), min_content_separation=int( vegas_config.get('min_content_separation', 24)), + min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), auto_trim=vegas_config.get('auto_trim', True), trim_threshold=int(vegas_config.get('trim_threshold', 10)), content_padding=int(vegas_config.get('content_padding', 8)), @@ -136,6 +145,7 @@ class VegasModeConfig: 'intra_plugin_gap': self.intra_plugin_gap, 'render_width_pct': self.render_width_pct, 'min_content_separation': self.min_content_separation, + 'min_cut_gap': self.min_cut_gap, 'auto_trim': self.auto_trim, 'trim_threshold': self.trim_threshold, 'content_padding': self.content_padding, @@ -238,6 +248,11 @@ class VegasModeConfig: "min_content_separation must be between 0 and 256, " f"got {self.min_content_separation}") + if not 1 <= self.min_cut_gap <= 128: + errors.append( + "min_cut_gap must be between 1 and 128, " + f"got {self.min_cut_gap}") + if self.intra_plugin_gap < 0: errors.append( f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}") @@ -305,6 +320,8 @@ class VegasModeConfig: if 'min_content_separation' in vegas_config: self.min_content_separation = int( vegas_config['min_content_separation']) + if 'min_cut_gap' in vegas_config: + self.min_cut_gap = int(vegas_config['min_cut_gap']) if 'auto_trim' in vegas_config: self.auto_trim = vegas_config['auto_trim'] if 'trim_threshold' in vegas_config: diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py index 85f8ad2c..2d6e3b8e 100644 --- a/src/vegas_mode/geometry.py +++ b/src/vegas_mode/geometry.py @@ -16,7 +16,7 @@ All column scans go through numpy: a Python-level per-column loop over a path. """ -from typing import NamedTuple, Optional, Tuple +from typing import List, NamedTuple, Optional, Tuple import numpy as np from PIL import Image @@ -192,6 +192,83 @@ def separation_gap( return max(minimum, target - existing, 0) +def blank_runs( + img: Image.Image, + min_run: int, + threshold: int = DEFAULT_INK_THRESHOLD, +) -> List[Tuple[int, int]]: + """ + Find maximal runs of blank columns at least ``min_run`` wide. + + Distinguishes item boundaries from letter spacing. Measured on real + rendered text, the gaps *between characters* are a single column, while the + gaps a plugin puts *between items* are 8px and up (the stocks ticker uses + 32px, baseball 48px). Treating any blank column as a cut point therefore + slices words in half; requiring a run excludes letter spacing. + + Args: + img: Image to scan + min_run: Minimum consecutive blank columns to qualify + threshold: Ink threshold + + Returns: + List of (start, end) half-open column ranges, in left-to-right order + """ + blank = ~column_has_ink(img, threshold) + if not blank.any(): + return [] + + # Vectorised run detection: pad with False so runs touching either edge get + # a boundary, then read starts and ends off the first difference. A Python + # loop here would be far too slow on a 17,000px ticker strip. + padded = np.concatenate(([False], blank, [False])) + diff = np.diff(padded.astype(np.int8)) + starts = np.flatnonzero(diff == 1) + ends = np.flatnonzero(diff == -1) + + long_enough = (ends - starts) >= max(1, min_run) + return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist())) + + +def find_item_boundary( + img: Image.Image, + target: int, + min_run: int, + threshold: int = DEFAULT_INK_THRESHOLD, +) -> Optional[int]: + """ + Find the column nearest ``target`` that sits inside a gap between items. + + Used to narrow an oversized segment without cutting through a word. Only + runs of at least ``min_run`` blank columns are considered, so the + single-column gaps between characters are never chosen — cutting there + orphaned the tail of a word into the following cycle, which is how a lone + "y" from "Wednesday" ended up floating between two unrelated plugins. + + Args: + img: Image to cut + target: Preferred cut column + min_run: Minimum blank-run width that counts as an item boundary + threshold: Ink threshold + + Returns: + A column inside a qualifying gap, or None when the image has no such + gap at all — in which case the caller must not cut it. + """ + runs = blank_runs(img, min_run, threshold) + if not runs: + return None + + # Nearest point of the nearest run. For a run left of target that is its + # end (content resumes just after), for a run right of target its start + # (content stopped just before) — the right choice in both directions. + def clamp_to_run(run: Tuple[int, int]) -> int: + start, end = run + return max(start, min(target, end - 1)) + + return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target)) + + def find_blank_cut( img: Image.Image, target: int, diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index e89c23d1..3f58598a 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -13,7 +13,7 @@ from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from PIL import Image from src.vegas_mode.geometry import ( - find_blank_cut, + blank_runs, separation_gap, trim_to_content, ) @@ -386,22 +386,48 @@ class PluginAdapter: if offset >= img.width: offset = 0 - # Snap both edges to blank columns. The search radius is generous - # enough to clear a wide glyph but small enough not to distort the - # requested budget much. - snap = max(8, self.display_width // 16) - start = find_blank_cut(img, offset, snap, self.config.trim_threshold) - end = find_blank_cut( - img, min(start + budget, img.width), snap, self.config.trim_threshold) - if end <= start: - end = min(start + budget, img.width) + # Cut only where the plugin left a real gap between items. Snapping to + # any blank column used to pick the single-column gaps between + # characters, splitting a word and orphaning its tail into the next + # cycle — a lone "y" from "Wednesday" floating between two unrelated + # plugins. Overshooting the budget is the lesser evil. + min_run = max(2, self.config.min_cut_gap) + gaps = blank_runs(img, min_run, self.config.trim_threshold) + + if not gaps: + # No internal gaps means continuous content — a map, a chart, a + # photo — where any column is as good as any other, so cut to the + # budget exactly. The gap rule exists to protect discrete items + # (words, ticker entries); it would be wrong to let a solid image + # escape the cap in its name. + end = min(offset + budget, img.width) + self._item_offsets[plugin_id] = 0 if end >= img.width else end + logger.info( + "[%s] Width budget %dpx: cropped continuous %dpx image to " + "[%d:%d] (no item gaps of %dpx+ to align to)", + plugin_id, budget, img.width, offset, end, min_run + ) + return img.crop((offset, 0, end, img.height)) + + # Cut mid-gap so the content either side keeps some breathing room. + cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps}) + + start = max((c for c in cuts if c <= offset), default=0) + later = [c for c in cuts if c > start] + if not later: + end = img.width + else: + within = [c for c in later if c <= start + budget] + # No boundary inside the budget: take the next one and overrun, + # because the alternative is cutting through an item. + end = max(within) if within else min(later) # Next cycle resumes where this one stopped; wrap when the strip ends. self._item_offsets[plugin_id] = 0 if end >= img.width else end logger.info( "[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] " - "(%dpx), window advances next cycle", + "(%dpx) at item boundaries, window advances next cycle", plugin_id, budget, img.width, start, end, end - start ) return img.crop((start, 0, end, img.height)) diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index e1e5589a..ccf3dc01 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -969,3 +969,132 @@ class TestRotationAcrossMultipleCycles: for _ in range(10): adapter.invalidate_cache('rows') assert adapter.get_content(NativePlugin(rows), 'rows') + + +def word_strip(words, letter_w=7, letter_gap=1, item_gap=32, height=DISPLAY_H): + """ + Build a ticker-like strip: 'words' of solid blocks separated by 1px, with + a wide gap between words. Mirrors real rendered text, where the measured + gap between characters is a single column and the gap between items is 8px+. + + Returns (image, list of (word_start, word_end) column ranges). + """ + spans = [] + x = 0 + for w_i, letters in enumerate(words): + start = x + for l_i in range(letters): + x += letter_w + if l_i < letters - 1: + x += letter_gap + spans.append((start, x)) + if w_i < len(words) - 1: + x += item_gap + img = Image.new('RGB', (x, height), (0, 0, 0)) + for w_i, letters in enumerate(words): + sx = spans[w_i][0] + for l_i in range(letters): + lx = sx + l_i * (letter_w + letter_gap) + img.paste(Image.new('RGB', (letter_w, height), (255, 255, 255)), (lx, 0)) + return img, spans + + +class TestCutsNeverSplitWords: + """ + A cut placed in a 1px inter-letter gap orphans the tail of a word into the + next cycle. That is what produced a lone "y" from "Wednesday" floating + between two unrelated plugins. + """ + + def test_cut_lands_in_an_item_gap_not_between_letters(self): + from src.vegas_mode.geometry import blank_runs + img, spans = word_strip([9, 9, 9, 9, 9]) # five 9-letter words + + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2, + min_cut_gap=6) + # Budget deliberately lands mid-word if cut naively. + images = adapter.get_content(NativePlugin([img]), 'ticker') + cut_width = images[0].width + + # Every qualifying gap midpoint is a legal cut; assert we used one. + legal = {0, img.width} | {(a + b) // 2 for a, b in blank_runs(img, 6)} + assert cut_width in {c for c in legal}, \ + f"cut at {cut_width} is not an item boundary; legal: {sorted(legal)}" + + def test_no_partial_letter_at_either_edge(self): + # A split letter shows as a lit column touching the crop edge with the + # rest of its glyph missing. Requiring the edges to be blank is the + # simplest way to assert we cut inside a gap. + from src.vegas_mode.geometry import column_has_ink + img, _ = word_strip([9, 9, 9, 9, 9, 9]) + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25, + min_cut_gap=6) + out = adapter.get_content(NativePlugin([img]), 'ticker')[0] + ink = column_has_ink(out) + assert not ink[0] or not ink[-1] or out.width == img.width, \ + "crop edges land on ink, so a glyph was cut through" + + def test_rotation_never_orphans_a_fragment(self): + # Walk the window across the whole strip and assert no slice is a + # narrow sliver, which is what an orphaned letter looks like. + img, _ = word_strip([9] * 8) + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2, + min_cut_gap=6) + widths = [] + for _ in range(12): + adapter.invalidate_cache('ticker') + out = adapter.get_content(NativePlugin([img]), 'ticker') + assert out + widths.append(out[0].width) + # A single 7px letter is the fragment signature; nothing that narrow. + assert min(widths) > 10, f"orphaned fragment in {widths}" + + def test_continuous_image_is_still_cut_to_budget(self): + # A map or chart has no item gaps; the gap rule must not let it escape + # the cap, because any column there is as good as another. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + min_cut_gap=6) + solid = canvas([(0, 4000)], width=4000) + out = adapter.get_content(NativePlugin([solid]), 'geochron')[0] + assert out.width == DISPLAY_W + + def test_overruns_budget_rather_than_splitting(self): + # One very long item with no internal gap: the cut must wait for the + # next real boundary even though that exceeds the budget. + img, spans = word_strip([80, 9], letter_gap=1, item_gap=32) + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.1, + min_cut_gap=6) + out = adapter.get_content(NativePlugin([img]), 'longitem')[0] + assert out.width > int(DISPLAY_W * 0.1), \ + "should overrun to the next boundary instead of cutting the item" + + def test_min_cut_gap_of_two_still_excludes_letter_spacing(self): + from src.vegas_mode.geometry import blank_runs + img, _ = word_strip([9, 9, 9]) + # 1px letter gaps must never qualify, whatever the setting. + assert all(end - start >= 2 for start, end in blank_runs(img, 2)) + + + def test_permissive_gap_rule_would_have_split_a_word(self): + """ + Pins the actual regression. With min_cut_gap=1 the 1px gaps between + letters qualify as cut points, so the crop lands inside a word; with the + default it can only land in the wide gaps between items. + """ + from src.vegas_mode.geometry import blank_runs + img, _ = word_strip([9, 9, 9, 9, 9]) + + letter_gaps = {(a + b) // 2 for a, b in blank_runs(img, 1)} + item_gaps = {(a + b) // 2 for a, b in blank_runs(img, 6)} + + # The permissive rule offers many more cut points, and the extra ones + # are exactly the mid-word positions. + assert len(letter_gaps) > len(item_gaps) + mid_word = letter_gaps - item_gaps + assert mid_word, "expected inter-letter gaps to exist in the fixture" + + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2, + min_cut_gap=6) + out = adapter.get_content(NativePlugin([img]), 'ticker')[0] + assert out.width not in mid_word, \ + f"cut at {out.width} is a mid-word position" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 2f7a1f8a..7df0942b 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -924,7 +924,7 @@ def save_main_config(): 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', 'vegas_min_cycle_duration', 'vegas_max_cycle_duration', 'vegas_intra_plugin_gap', 'vegas_render_width_pct', - 'vegas_min_content_separation'] + 'vegas_min_content_separation', 'vegas_min_cut_gap'] if any(k in data for k in vegas_fields): if 'display' not in current_config: @@ -977,6 +977,7 @@ def save_main_config(): 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), 'vegas_render_width_pct': ('render_width_pct', 10, 100), 'vegas_min_content_separation': ('min_content_separation', 0, 256), + 'vegas_min_cut_gap': ('min_cut_gap', 1, 128), 'vegas_target_fps': ('target_fps', 30, 200), 'vegas_buffer_ahead': ('buffer_ahead', 1, 5), 'vegas_trim_threshold': ('trim_threshold', 0, 254), diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 2b8cde7d..e2a271e3 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -623,6 +623,19 @@ class="form-control"> + +
+
+ + +
+