Линейная интерполяция с плавающей запятой

Чтобы выполнить линейную интерполяцию между двумя переменными a и b с учетом дроби f, я сейчас использую этот код:

float lerp(float a, float b, float f) 
{
    return (a * (1.0 - f)) + (b * f);
}

Я думаю, что есть, вероятно, более эффективный способ сделать это. Я использую микроконтроллер без FPU, поэтому операции с плавающей запятой выполняются программно. Они достаточно быстрые, но для сложения или умножения требуется около 100 циклов.

Какие-либо предложения?

Примечание. для ясности уравнения в приведенном выше коде мы можем не указывать 1.0 как явный литерал с плавающей запятой.


person Thomas O    schedule 04.12.2010    source источник


Ответы (7)


Если не принимать во внимание разницу в точности, это выражение эквивалентно

float lerp(float a, float b, float f)
{
    return a + f * (b - a);
}

Это 2 сложения/вычитания и 1 умножение вместо 2 сложений/вычитаний и 2 умножения.

person aioobe    schedule 04.12.2010
comment
Это не эквивалентный алгоритм из-за потери точности, когда показатели a и b значительно различаются. Алгоритм ОП всегда лучший выбор. Например, алгоритм в этом ответе для lerp(-16.0e30, 16.0, 1.0) вернет 0, а не правильный результат 16, который дает алгоритм OP. Потеря точности происходит в операторе сложения, когда a значительно больше, чем f * (b - a), и в операторе вычитания в (b - a). - person Jason C; 18.05.2014
comment
Исходный алгоритм также не сильно снижает производительность: умножение FP происходит намного быстрее, чем сложение FP, и если f гарантированно находится между 0 и 1, возможны определенные оптимизации (1-f). - person Sneftel; 18.05.2014
comment
@Sneftel: Можете ли вы рассказать об оптимизации для 1 - f? Я оказался в такой ситуации, и мне любопытно :D - person Levi Morrison; 11.09.2014
comment
@LeviMorrison По сути, это гарантирует, что показатель степени для f неположителен (и, конечно, гарантирует фиксированные значения для мантиссы и показателя степени 1), вырезая большинство ветвей в процедуре вычитания. - person Sneftel; 11.09.2014
comment
@JasonC О потере точности: бывают случаи, когда мы используем f, который не обязательно находится между [0,0, 1,0] (например, параметрическое определение линии). Какой из исходных lerp и этот будет более точным, когда, например. f достаточно велик по сравнению с b, a или, может быть, b - a? - person coredump; 24.02.2015
comment
@coredump Извините, что не заметил вашего комментария 2 года назад (хех...). OP по-прежнему будет более точным, в частности, если f * (b - a) значительно отличается по величине, чем a в этом алгоритме, то сложение разваливается. Это сложение/вычитание, где вы столкнетесь с проблемой. Тем не менее, даже OP может выйти из строя, если f слишком велико по сравнению с 1.0f, поскольку 1.0f - f может стать эквивалентом -f для очень большого f. Поэтому, если вы работаете с огромными значениями для f, вам нужно немного подумать о математике. Проблема в том, что вы сталкиваетесь с такими вещами, как 1.0 + 1.0e800 == 1.0e800. - person Jason C; 20.03.2017
comment
Просто подумайте о числах с плавающей запятой как о мантиссе с фиксированной запятой и экспоненте (это сложнее, но такого просмотра достаточно, чтобы обнаружить многие проблемные области). Поэтому, если вы превысите точность мантиссы, вы начнете терять информацию. Концептуально аналогично тому факту, что мы не можем, например, представить 1 230 000 в десятичном виде только с двумя значащими цифрами (1,2 * 10^6 — самое близкое, что мы можем получить), поэтому, если вы сделаете 1 200 000 + 30 000, но у вас будет только две значащие цифры в в вашем распоряжении, вы теряете эти 30 000. - person Jason C; 20.03.2017
comment
@JasonC Спасибо! Я забыл об этом вопросе :-) - person coredump; 20.03.2017
comment
Хотя часто это может быть лучшим выбором, алгоритм OP всегда не является лучшим выбором. Рассмотрим случай, когда a == b: этот алгоритм всегда будет возвращать правильный ответ, но в зависимости от значения t алгоритм ОП может потерять точность как в левой, так и в правой части сложения, и он не будет складываться к начальному значению. - person nemetroid; 30.04.2020

Предполагая, что математика с плавающей запятой доступна, алгоритм OP является хорошим и всегда превосходит альтернативный a + f * (b - a) из-за потери точности, когда a и b значительно различаются по величине.

Например:

// OP's algorithm
float lint1 (float a, float b, float f) {
    return (a * (1.0f - f)) + (b * f);
}

// Algebraically simplified algorithm
float lint2 (float a, float b, float f) {
    return a + f * (b - a);
}

В этом примере, предполагая, что 32-битные числа с плавающей точкой lint1(1.0e20, 1.0, 1.0) правильно вернут 1,0, тогда как lint2 неправильно вернет 0,0.

Большая часть потерь точности приходится на операторы сложения и вычитания, когда операнды значительно различаются по величине. В приведенном выше случае виновниками являются вычитание в b - a и сложение в a + f * (b - a). Алгоритм OP не страдает от этого, поскольку компоненты полностью перемножаются перед добавлением.


Для случая a=1e20, b=1 приведен пример разных результатов. Программа испытаний:

#include <stdio.h>
#include <math.h>

float lint1 (float a, float b, float f) {
    return (a * (1.0f - f)) + (b * f);
}

float lint2 (float a, float b, float f) {
    return a + f * (b - a);
}

int main () {
    const float a = 1.0e20;
    const float b = 1.0;
    int n;
    for (n = 0; n <= 1024; ++ n) {
        float f = (float)n / 1024.0f;
        float p1 = lint1(a, b, f);
        float p2 = lint2(a, b, f);
        if (p1 != p2) {
            printf("%i %.6f %f %f %.6e\n", n, f, p1, p2, p2 - p1);
        }
    }
    return 0;
}

Вывод, немного скорректированный под форматирование:

    f            lint1               lint2             lint2-lint1
0.828125  17187500894208393216  17187499794696765440  -1.099512e+12
0.890625  10937500768952909824  10937499669441282048  -1.099512e+12
0.914062   8593750447104196608   8593749897348382720  -5.497558e+11
0.945312   5468750384476454912   5468749834720641024  -5.497558e+11
0.957031   4296875223552098304   4296874948674191360  -2.748779e+11
0.972656   2734375192238227456   2734374917360320512  -2.748779e+11
0.978516   2148437611776049152   2148437474337095680  -1.374390e+11
0.986328   1367187596119113728   1367187458680160256  -1.374390e+11
0.989258   1074218805888024576   1074218737168547840  -6.871948e+10
0.993164    683593798059556864    683593729340080128  -6.871948e+10
1.000000                     1                     0  -1.000000e+00
person Jason C    schedule 17.05.2014
comment
Интересно, что версия OP не всегда лучше. Я подумал, что тогда его укусил вот такой пример: lerp(0.45, 0.45, 0.81965185546875). Это, очевидно, должно дать 0,45, но, по крайней мере, для двойной точности я получаю 0,450000000000000007, тогда как версия a + (b-a)*f дает a, когда a==b. Мне бы очень хотелось увидеть алгоритм со свойством, что lerp(a, b, f) возвращает a, если f==0, b, если f==1, и остается в диапазоне [a,b] для f в [0,1]. - person Ben; 22.12.2016
comment
Во-первых, вам нужен чехол if a == b -> return a. Однако точно 0,45 невозможно представить с двойной точностью или точностью с плавающей запятой, поскольку это не точная степень числа 2. В вашем примере все параметры a, b, f сохраняются как двойные, когда внутри вызова функции — возврат a никогда не вернет ровно 0,45. (Конечно, в случае явно типизированных языков, таких как C) - person Benjamin R; 20.03.2017

Если вы используете микроконтроллер без FPU, то операции с плавающей запятой будут очень дорогими. Легко может быть в двадцать раз медленнее для операции с плавающей запятой. Самое быстрое решение — просто выполнить всю математику, используя целые числа.

Количество мест после фиксированной двоичной точки (http://blog.credland.net/2013/09/binary-fixed-point-explanation.html?q=fixed+binary+point): XY_TABLE_FRAC_BITS.

Вот функция, которую я использую:

inline uint16_t unsignedInterpolate(uint16_t a, uint16_t b, uint16_t position) {
    uint32_t r1;
    uint16_t r2;

    /* 
     * Only one multiply, and one divide/shift right.  Shame about having to
     * cast to long int and back again.
     */

    r1 = (uint32_t) position * (b-a);
    r2 = (r1 >> XY_TABLE_FRAC_BITS) + a;
    return r2;    
}

Со встроенной функцией это должно быть прибл. 10-20 циклов.

Если у вас есть 32-битный микроконтроллер, вы сможете использовать большие целые числа и получать большие числа или большую точность без ущерба для производительности. Эта функция использовалась в 16-битной системе.

person Jim Credland    schedule 27.01.2014
comment
Я прочитал веб-сайт, но все еще немного смущен тем, какая позиция должна быть. Это значение от 0 до 0xFFFF? или от 0 до 0xFFFE? И что такое XY_TABLE_FRAC_BITS? 8? - person jjxtra; 06.04.2017
comment
@jjxtra: XY_TABLE_FRAC_BITS - это просто (плохо) именованная целочисленная константа, значение которой указывает, где находится предполагаемая двоичная точка в используемых целочисленных значениях с фиксированной точкой (поскольку она не плавает в них, как в числах с плавающей запятой). - person martineau; 17.10.2018

Если вы пишете код для микроконтроллера без операций с плавающей запятой, то лучше вообще не использовать числа с плавающей запятой, а использовать арифметика с фиксированной точкой вместо этого.

person Gareth Rees    schedule 04.12.2010
comment
Я планирую перейти на фиксированную точку, но с плавающей точкой уже довольно быстро. - person Thomas O; 04.12.2010

Стоит отметить, что стандартные формулы линейной интерполяции f1(t)=a+t(ba), f2(t)=b-(ba)(1-t) и f3(t)=a(1- t)+bt не гарантирует монотонности при использовании арифметики с плавающей запятой. В частности, если a != b, не гарантируется, что f1(1.0) == b или f2(0.0) == a, а для a == b не гарантируется, что f3(t) будет равно a , когда 0 ‹ t ‹ 1.

Эта функция работала для меня на процессорах, поддерживающих IEEE754 с плавающей запятой, когда мне нужно, чтобы результаты были монотонными (я использую ее с двойной точностью, но с плавающей запятой тоже должно работать):

double lerp(double a, double b, double t) 
{
    if (t <= 0.5)
        return a+(b-a)*t;
    else
        return b-(b-a)*(1.0-t);
}
person 0kcats    schedule 31.10.2019
comment
В c++20 они добавили std::lerp, что гарантирует монотонное поведение. - person 0kcats; 04.12.2020

Начиная с C++20 вы можете использовать std::lerp(), который, вероятно, будет наилучшая возможная реализация для вашей цели.

person tambre    schedule 03.03.2020
comment
На мой взгляд, std::lerp не следует использовать ровно нигде. Очень редко вам действительно нужны и интерполяция, и экстраполяция, а также тонна поведения ветвления, поверх численно нестабильной внутренней реализации. У меня так много разногласий по поводу того, как был реализован std::lerp, что трудно рекомендовать. - person jeremyong; 22.09.2020

Если вы хотите, чтобы окончательный результат был целым числом, может быть быстрее использовать целые числа и для ввода.

int lerp_int(int a, int b, float f)
{
    //float diff = (float)(b-a);
    //float frac = f*diff;
    //return a + (int)frac;
    return a + (int)(f * (float)(b-a));
}

Это умножает два броска и один поплавок. Если на вашей платформе приведение выполняется быстрее, чем сложение/вычитание с плавающей запятой, и если вам полезен целочисленный ответ, это может быть разумной альтернативой.

person mtrw    schedule 04.12.2010
comment
Для f * (b - a) повышение типа будет означать, что (b - a) будет повышен до float, поскольку f относится к типу float. Итак, явное приведение к (float) в (float)(b - a) в лучшем случае иллюстративно, но на самом деле не обязательно, не так ли? - person Scheff's Cat; 01.04.2020
comment
@Scheff - да, вы правы, приведение поплавка написано исключительно для того, чтобы привлечь внимание к чему-то, что компилятор все равно вставит. - person mtrw; 01.04.2020