Вот что видит пользователь, когда мы рисуем график давления в скважине за два года — 50 миллионов точек:

Слева — идеальная отрисовка. Справа — LTTB. Потеряно 59.2% плотности.
Слева — идеальная отрисовка. Справа — LTTB. Потеряно 59.2% плотности.

Мы решаем эту задачу через матричный фильтр. На датасете в 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 достаточно числа точек, соизмеримого с количеством пиксельных столбцов на изображении. Мы даём им больше: столько, сколько нужно для полного двумерного покрытия.

Как мы оценивали качество визуализации

Для каждого алгоритма мы строили график в виртуальном буфере и сравнивали его попиксельно с графиком по сырым, непрореженным данным.

Сравнение проводилось в двух режимах рендера:

  1. Scatter — точки размером 2×2 пикселя, без соединительных линий.

  2. Lines — классические линии толщиной 1px.

Метрики:

  • Coverage — процент пикселей оригинала, которые покрыл алгоритм. Не потеряли ли мы данные?

  • Precision — процент закрашенных пикселей алгоритма, присутствующих в оригинале. Не нарисовали ли мы лишнего?

  • F1-score — гармоническое среднее Coverage и Precision.

  • Visual Score — тот же F1, но с допуском ±1 пиксель. Из-за округлений координат точка может сдвинуться на субпиксель: строгий F1 это фиксирует, глаз — нет. Visual Score ближе к реальному восприятию.

Датасеты

Данные разбиты на 3 группы:

  1. Regular (2 930 файлов, в среднем ~20 000 точек). Основной датасет для оценки качества: широкий охват разных типов кривых, датчиков и режимов записи. Акцент здесь не на скорости — на том, как точно алгоритмы воспроизводят scatter и line chart после прореживания.

  2. Medium (24 файла, в среднем ~500 000 точек). Данные за периоды от нескольких месяцев до года непрерывной записи.

  3. 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