Вот что видит пользователь, когда мы рисуем график давления в скважине за два года — 50 миллионов точек:
Мы решаем эту задачу через матричный фильтр. На датасете в 50 млн точек это даёт ~100% Coverage, ~100% Visual Score. LTTB на тех же данных — 16.4% и 40.8% соответственно. По производительности мы остаёмся в тех же пределах.
Под катом — почему стандартные алгоритмы фундаментально не подходят для scatter-графиков, как устроен наш подход и результаты бенчмарка на ~3 000 реальных промышленных датасетах от 19 тысяч до 50+ миллионов точек.
Сразу важный дисклеймер: для примера выше мы намерено выбрали худший кейс, чтобы отобразить проблему наглядно.
Большинство систем визуализации работают с линейными графиками — и для них классические алгоритмы даунсемплинга справляются. При правильном подборе алгоритма и бюджета точек артефакты минимальны или незаметны.
Проблема начинается там, где линия — это не упрощение, а искажение. Она соединяет точки, между которыми ничего не происходило, сглаживает разрывы и создаёт ложную непрерывность. В таких случаях единственный честный способ визуализации — scatter-график.
Почему мы используем scatter, а не line chart
Наш контекст — исследование нефтяных скважин: десятки лет данных в одной куче, где ручные единичные замеры соседствуют со старой нестабильной телеметрией и современными высокоточными датчиками. У нас три конкретные причины:
Технические пропуски — не пустота, а слепая зона. Когда запись прерывается из-за сбоя, давление в этот момент могло вести себя как угодно. Линия соединит точки до и после разрыва плавным переходом — и исследователь увидит «стабильную работу» там, где была авария. Точки честно показывают: здесь данных нет.
Нерегулярная дискретность делает алгоритм соединения невозможным. Разные датчики, разные эпохи, разные частоты записи. Чтобы корректно рвать линию там, где пропуск слишком большой — нужен порог. Универсального порога не существует. Точкам правила соединения не нужны вообще.
Паттерны лучше видны без линий. Мы ищем конкретные режимы и аномалии. На scatter-графике глаз сразу считывает плотность, кластеры и выбросы. Линии создают визуальный шум, который эти паттерны скрывает.
Стандартные алгоритмы проектировались под line chart — они оптимизируют сохранение формы кривой, а не структуры данных.
Как работают классические алгоритмы даунсемплинга
Почти все популярные алгоритмы работают по одному принципу: разбивают ось времени на интервалы («корзины») и по определённой логике выбирают из каждой корзины одну или несколько точек.
MinMax — самый простой и быстрый. Из каждого интервала берёт две точки: минимум и максимум по Y. Все пики и провалы сохраняются, но распределение данных внутри корзины полностью теряется.
M4 — разбивает время на интервалы шириной в один пиксель и для каждого берёт четыре точки: первую, последнюю, минимум и максимум. Для линейных графиков даёт практически pixel-perfect результат.
LTTB (Largest Triangle Three Buckets) — де-факто стандарт для фронтенда. В каждой корзине ищет точку, которая образует треугольник наибольшей площади с соседними корзинами. Отлично сохраняет визуальный «силуэт» линии.
MinMaxLTTB — гибрид: сначала MinMax фиксирует критические экстремумы, затем LTTB сглаживает результат. Попытка совместить точность пиков и визуальную гладкость.
Van Der Donckt J., Van Der Donckt J., Van Hoecke S. tsdownsample: high-performance time series downsampling for scalable visualization // SoftwareX. — 2025. — arXiv:2307.05389
Все они работают только вдоль оси X. Возьмём пиксельный столбец, в который попало 100 точек, разбросанных по всей высоте экрана: MinMax оставит 2, M4 — 4, LTTB — 1. Для линейного графика этого достаточно — линия сама закроет вертикаль между крайними точками. На scatter-графике мы потеряем 96–99% данных в этом столбце: плотное облако схлопнется в редкий скелет из экстремумов. Форма кривой сохранится, внутренняя структура — нет.
Матричный фильтр: двумерное прореживание
Принцип, который мы используем, давно известен в компьютерной графике под названием Voxel Grid Filter. Ключевой принцип: пространство разбивается на дискретную сетку ячеек, внутри каждой сохраняется только одна точка, избыточная плотность отсекается. Мы проецируем эту логику на плоскость, где роль вокселей играют физические пиксели экрана.
Входные данные: два массива double[] times, double[] values. Разрешение области построения W × H в пикселях. Порядок точек роли не играет — в отличие от LTTB и M4, алгоритму не нужна сортировка по времени.
Шаг 1 — Границы. Один проход по массивам: находим tMin, tMax, vMin, vMax. Если метаданные уже известны из БД, шаг пропускается.
Шаг 2 — Сетка. Создаём плоский булев массив размером W × H. Это виртуальная проекция будущего графика на пиксели экрана, изначально пустая.
Шаг 3 — Фильтрация. Для каждой точки вычисляем её пиксельные координаты (px, py) и проверяем ячейку grid[py * W + px]. Если пиксель свободен — точка попадает в результат, пиксель помечается занятым. Если занят — точка отбрасывается.
public static void downsample( double[] times, double[] values, int width, int height, List<double[]> result) { int n = times.length; if (n == 0) return; // Шаг 1: границы double tMin = times[0], tMax = times[0]; double vMin = values[0], vMax = values[0]; for (int i = 1; i < n; i++) { if (times[i] < tMin) tMin = times[i]; if (times[i] > tMax) tMax = times[i]; if (values[i] < vMin) vMin = values[i]; if (values[i] > vMax) vMax = values[i]; } double tRange = Math.max(tMax - tMin, 1e-10); double vRange = Math.max(vMax - vMin, 1e-10); // Шаг 2: сетка boolean[] grid = new boolean[width * height]; // Шаг 3: фильтрация for (int i = 0; i < n; i++) { int px = (int) Math.min(width - 1, (times[i] - tMin) / tRange * (width - 1)); int py = (int) Math.min(height - 1, (values[i] - vMin) / vRange * (height - 1)); int idx = py * width + px; if (!grid[idx]) { grid[idx] = true; result.add(new double[]{times[i], values[i]}); } }}
Максимальный размер выходного массива ограничен W × H — числом пикселей экрана. На практике он всегда меньше: точки распределены неравномерно, и большинство пикселей остаются пустыми.
Почему это работает: рендеринг против математики
Секрет визуального сходства в том, что матричный фильтр решает задачу не математического сжатия ряда, а прямого рендеринга.
Классические алгоритмы прореживают данные вдоль оси X, сохраняя форму кривой. В плотных участках они неизбежно оставляют пустоты. Матричный фильтр переносит задачу в плоскость монитора: вычисляет, какие пиксели активировались бы при отрисовке всех точек, и оставляет ровно по одной точке на каждый такой пиксель.
Из этого следует важное ограничение. Метод оптимизирован под отрисовку — и только под неё. Какая именно точка остаётся из группы, попавшей в один пиксель, определяется порядком обхода, а не аналитической значимостью. Для задач, где важна выборка как таковая — статистика, дальнейшие вычисления, экспорт — этот подход не подходит. Его область применения строго одна: показать данные так, как они выглядели бы при полной отрисовке.
Методология тестирования: как измерить «идеальность» графика
Все тесты проводились на реальной исторической телеметрии — логах забойного давления нефтяных скважин. В этих данных есть всё: долгие базовые линии, плавные тренды, периодические колебания, резкие пики от остановок и запусков и большие пропуски в записях.
Тестовый стенд
Тесты запускались в один поток:
-
CPU: Intel Core i7-7700K (4.20 GHz)
-
RAM: 32 ГБ
-
ОС: Windows 10 Pro
-
Стек: Java 17 (OpenJDK), тесты через JUnit 5
Каждый датасет прогонялся через 6 алгоритмов: MatrixFilter 1920×1080 (эталон), MatrixFilter 800×600 (оценка работы при меньшем разрешении), M4, MinMax, LTTB и MinMaxLTTB (×4).
Бюджет точек для классических алгоритмов определялся через сам матричный фильтр: сначала запускается MatrixFilter 1920×1080, и количество точек на его выходе становится targetSize для всех остальных. Это заведомо щедрый лимит — классическим алгоритмам для качественного line chart достаточно числа точек, соизмеримого с количеством пиксельных столбцов на изображении. Мы даём им больше: столько, сколько нужно для полного двумерного покрытия.
Как мы оценивали качество визуализации
Для каждого алгоритма мы строили график в виртуальном буфере и сравнивали его попиксельно с графиком по сырым, непрореженным данным.
Сравнение проводилось в двух режимах рендера:
-
Scatter — точки размером 2×2 пикселя, без соединительных линий.
-
Lines — классические линии толщиной 1px.
Метрики:
-
Coverage — процент пикселей оригинала, которые покрыл алгоритм. Не потеряли ли мы данные?
-
Precision — процент закрашенных пикселей алгоритма, присутствующих в оригинале. Не нарисовали ли мы лишнего?
-
F1-score — гармоническое среднее Coverage и Precision.
-
Visual Score — тот же F1, но с допуском ±1 пиксель. Из-за округлений координат точка может сдвинуться на субпиксель: строгий F1 это фиксирует, глаз — нет. Visual Score ближе к реальному восприятию.
Датасеты
Данные разбиты на 3 группы:
-
Regular (2 930 файлов, в среднем ~20 000 точек). Основной датасет для оценки качества: широкий охват разных типов кривых, датчиков и режимов записи. Акцент здесь не на скорости — на том, как точно алгоритмы воспроизводят scatter и line chart после прореживания.
-
Medium (24 файла, в среднем ~500 000 точек). Данные за периоды от нескольких месяцев до года непрерывной записи.
-
Large (13 файлов, в среднем ~25 000 000 точек, максимум — более 50 млн). Проверка поведения алгоритмов на предельных объёмах: качество картинки и замер производительности.
Результаты тестирования: цифры и визуализация
Ниже — результаты прогонов по каждой из трёх групп. В таблицах оставлена только суть:
-
Avg Out — среднее количество точек на выходе алгоритма;
-
Total ms — суммарное время работы на всём датасете;
-
Cover, Prec, F1 — строгие попиксельные метрики (полнота, точность, их баланс);
-
Visual — F1-score с допуском ±1 пиксель, ближе к реальному восприятию глазом.
Этап 1: Базовый датасет (Regular)
Параметры: 2 930 файлов | В среднем ~20 000 точек на файл. Стандартный сценарий: данные с манометров за 1–12 месяцев, низкая дискретность.
Scatter-графики (Точечные)
|
Algorithm |
Avg Out |
Total ms |
Cover |
Prec |
Visual |
F1 |
|---|---|---|---|---|---|---|
|
MatrixFilter 1920×1080 |
4 939 |
695 |
100% |
100% |
100% |
100% |
|
MatrixFilter 800×600 |
2 867 |
497 |
84.6% |
100% |
99.6% |
91.5% |
|
MinMaxLTTB (x4) |
4 932 |
494 |
84.1% |
100% |
95.2% |
90.9% |
|
LTTB |
4 939 |
366 |
82.2% |
100% |
94.2% |
89.6% |
|
MinMax |
4 027 |
251 |
76.0% |
100% |
91.8% |
85.5% |
|
M4 |
3 311 |
258 |
63.5% |
100% |
87.4% |
76.5% |
Наблюдения по Scatter: На большой выборке реальных данных классические алгоритмы в среднем теряют около 20% покрытия, хотя визуально потери ощущаются мягче — на уровне 5–10%. Матричный фильтр 800×600 показал почти идеальное визуальное качество, вернув при этом вдвое меньше точек. Разница во времени работы на этом объёме незначительна: сами алгоритмы отрабатывают за миллисекунды, и накладные расходы влияют сильнее, чем выбор метода.

Line-графики (Линейные)
|
Algorithm |
Avg Out |
Total ms |
Cover |
Prec |
Visual |
F1 |
|---|---|---|---|---|---|---|
|
MatrixFilter 1920×1080 |
4 939 |
693 |
99.8% |
99.3% |
100% |
99.5% |
|
MinMaxLTTB (x4) |
4 932 |
462 |
91.9% |
94.1% |
99.1% |
93.0% |
|
LTTB |
4 939 |
331 |
90.0% |
93.0% |
98.2% |
91.4% |
|
MinMax |
4 027 |
250 |
87.8% |
90.8% |
98.9% |
89.2% |
|
MatrixFilter 800×600 |
2 867 |
493 |
85.6% |
93.0% |
99.4% |
89.0% |
|
M4 |
3 311 |
254 |
80.4% |
85.9% |
97.4% |
82.9% |
Наблюдения по Line: При отрисовке линий разрыв между матричным фильтром и классическими алгоритмами минимален. Visual Score у всех — выше 97%. Соединительная линия закрашивает вертикальные пустоты, которые на scatter-графике были бы заметны.

Этап 2: Данные среднего размера (Medium)
Параметры: 24 файла | В среднем 500 000 точек на файл. Более плотные данные: непрерывная запись за периоды до года.
Scatter-графики (Точечные)
|
Algorithm |
Avg Out |
Total ms |
Cover |
Prec |
Visual |
F1 |
|---|---|---|---|---|---|---|
|
MatrixFilter 1920×1080 |
4 744 |
91 |
100% |
100% |
100% |
100% |
|
MatrixFilter 800×600 |
2 571 |
91 |
82.9% |
100% |
99.5% |
90.6% |
|
MinMaxLTTB (x4) |
3 249 |
29 |
38.5% |
100% |
68.8% |
54.3% |
|
LTTB |
4 744 |
37 |
31.5% |
100% |
60.3% |
45.8% |
|
MinMax |
1 584 |
33 |
24.6% |
100% |
51.2% |
37.9% |
|
M4 |
1 328 |
35 |
20.1% |
100% |
45.4% |
32.2% |
Наблюдения по Scatter: С ростом плотности классические алгоритмы начинают терять покрытие. LTTB сохраняет лишь 31.5% исходного облака, M4 — 20.1%. Визуально графики становятся разреженными: плотные области теряют структуру. Матричный фильтр остаётся стабильным независимо от объёма.
Line-графики (Линейные)
|
Algorithm |
Avg Out |
Total ms |
Cover |
Prec |
Visual |
F1 |
|---|---|---|---|---|---|---|
|
MatrixFilter 1920×1080 |
4 744 |
97 |
99.0% |
99.0% |
99.8% |
99.0% |
|
MinMaxLTTB (x4) |
3 249 |
29 |
95.6% |
96.3% |
99.7% |
95.9% |
|
MatrixFilter 800×600 |
2 571 |
80 |
92.3% |
96.6% |
99.5% |
94.4% |
|
MinMax |
1 584 |
39 |
87.0% |
89.1% |
98.7% |
88.0% |
|
M4 |
1 328 |
35 |
79.9% |
85.8% |
98.5% |
82.7% |
|
LTTB |
4 744 |
38 |
66.6% |
75.0% |
83.0% |
70.2% |
Наблюдения по Line: На линейных графиках ситуация принципиально иная. Вертикальные отрезки линий компенсируют потерю отдельных точек, показывая почти идеальную визуализацию — Visual > 98%.
Отдельного внимания заслуживает просадка чистого LTTB до 83% по визуализации. Причина — в механике алгоритма: он оставляет строго по одной точке на корзину. В зонах с высокой плотностью и большим разбросом по вертикали одной точки недостаточно, чтобы передать «толщину» зашумлённого сигнала — линия начинает отклоняться от оригинала.
Этап 3: Большие данные (Large)
Параметры: 13 файлов | В среднем 25 000 000 точек на файл (максимум — более 50 млн). Стресс-тест на предельных объёмах.
Примечание: выборка на этом этапе малая, поэтому процентные метрики следует воспринимать как оценочные. Основная цель — замер производительности.
Scatter-графики (Точечные)
|
Algorithm |
Avg Out |
Total ms |
Cover |
Prec |
Visual |
F1 |
|---|---|---|---|---|---|---|
|
MatrixFilter 1920×1080 |
13 216 |
1 657 |
100% |
100% |
100% |
100% |
|
MatrixFilter 800×600 |
5 545 |
1 606 |
79.9% |
100% |
99.4% |
88.8% |
|
MinMaxLTTB (x4) |
12 670 |
636 |
37.8% |
100% |
70.5% |
54.5% |
|
LTTB |
13 216 |
643 |
33.0% |
100% |
63.2% |
49.2% |
|
MinMax |
5 745 |
649 |
28.9% |
100% |
59.7% |
44.1% |
|
M4 |
5 520 |
742 |
24.2% |
100% |
53.8% |
38.3% |
Line-графики (Линейные)
|
Algorithm |
Avg Out |
Total ms |
Cover |
Prec |
Visual |
F1 |
|---|---|---|---|---|---|---|
|
MatrixFilter 1920×1080 |
13 216 |
1 691 |
99.6% |
98.4% |
99.9% |
98.9% |
|
MinMaxLTTB (x4) |
12 670 |
634 |
95.1% |
96.7% |
98.4% |
95.9% |
|
MinMax |
5 745 |
615 |
94.9% |
96.1% |
99.9% |
95.5% |
|
M4 |
5 520 |
738 |
91.4% |
94.3% |
100% |
92.8% |
|
MatrixFilter 800×600 |
5 545 |
1 600 |
84.5% |
95.8% |
99.5% |
89.7% |
|
LTTB |
13 216 |
637 |
71.8% |
83.6% |
81.2% |
76.0% |
Наблюдения: Результаты подтверждают тенденцию предыдущего этапа. На scatter-графиках классические алгоритмы продолжают терять покрытие — у LTTB оно составляет 33%. На линейных графиках ситуация выравнивается: M4 выдаёт 100% Visual Score, выполняя именно ту задачу, под которую проектировался. На этом объёме показательна степень сжатия: все алгоритмы укладывают 25 миллионов точек в 5–13 тысяч — разница лишь в том, что именно из исходной картины сохраняется.
Ключевой показатель на этом этапе — время работы. Матричный фильтр обрабатывает 13 файлов (около 324 млн точек суммарно) за 1657 мс — примерно в 2.5–3 раза медленнее классических алгоритмов. Это прямое следствие двойного прохода: сначала поиск экстремумов для границ сетки, затем растеризация. Размер матрицы (1920×1080 против 800×600) на скорость практически не влияет — меньшая сетка экономит только память и количество возвращаемых точек.

Главные выводы
Результаты всех прогонов сводятся к нескольким фактам:
-
Классические алгоритмы непригодны для scatter. Одномерные алгоритмы (LTTB, M4, MinMax) деградируют на точечных графиках пропорционально росту данных. Они отбрасывают 10–30% визуально значимой информации, разрежая плотные области до скелета из экстремумов.
-
Матричный фильтр сохраняет плотность. На точечных графиках он абсолютно стабилен. Опора на физические пиксели, а не на ось времени, гарантирует 100% покрытие независимо от объёма данных на входе.
-
Линейные графики компенсируют потери. Для line chart разрыв в качестве минимален. Соединительная линия закрашивает вертикальные пустоты в пиксельных столбцах, вытягивая метрики классических алгоритмов.
-
M4 решает свою задачу. На линейных графиках и больших объёмах M4 показал 100% Visual Score. Для line chart это по-прежнему быстрое и точное решение.
-
Слабость чистого LTTB. На зашумлённых данных, где в один пиксельный столбец попадают сотни точек с большим разбросом амплитуды, LTTB проседает даже на линейных графиках. Одна точка на корзину — недостаточно для передачи визуальной «толщины» сигнала.
-
Размер матрицы — рычаг для трафика. Уменьшение сетки с 1920×1080 до 800×600 практически не снижает визуальное качество (~99% Visual Score), но возвращает в 2–2.5 раза меньше точек.
-
Размер матрицы не влияет на скорость. Фильтр 800×600 работает за то же время, что и Full HD. Меньшая сетка экономит память, но не процессорное время.
-
Матричный фильтр медленнее в 2–3 раза. Это следствие двойного прохода по массиву: сначала поиск границ, затем растеризация.
-
В абсолютных величинах скорость достаточна. 25 миллионов точек обрабатываются примерно за 130 мс в один поток. На таких объёмах разница легко растворяется в накладных расходах: сериализация, сеть, рендеринг в браузере.
-
Единый подход. Матричный фильтр одинаково точно работает и на scatter, и на line chart. Это позволяет использовать один алгоритм вместо набора специализированных — при условии, что задача сводится к визуализации.
Заключение
Если продукт отображает исключительно линейные графики, матричный фильтр, вероятно, не нужен. M4 или MinMaxLTTB работают в один проход, считают быстрее и дают отличный результат на непрерывных кривых.
Но в промышленной телеметрии — где данные рвутся, датчики шумят, а scatter-график остаётся основным инструментом аналитика — одномерные алгоритмы систематически искажают картину, отбрасывая визуально значимую плотность. Для таких задач двумерное прореживание оказывается более релевантным.
Матричный фильтр медленнее в 2–3 раза. На практике это десятки миллисекунд, которые растворяются в накладных расходах стека. Взамен мы получаем пиксельно точную визуализацию и единый алгоритм, одинаково пригодный для любого типа графика.
Исходный код
Код эксперимента — все фильтры и тестовые стенды — выделен в открытый репозиторий. Алгоритм реализован на Java без сторонних библиотек, легко читается и переносится на любой другой язык.
Исходный код на GitHub: https://github.com/gearquicker/time-series-filter
ссылка на оригинал статьи https://habr.com/ru/articles/1022894/