feat(waterfall): палитры Inferno/Turbo + gamma, разрешение до 4096, HiDPI

- SPECTRUM_PIXELS 1024→4096 + настройка display_pixels (лимит точек
  анализатора, SetDisplayResLimit); web-зеркало остаётся 1024 —
  max-децимация в SnapshotPixels
- палитры водопада Classic/Inferno/Turbo + регулятор gamma (в LUT),
  настройки wf_palette/wf_gamma, страница Waterfall → группа Rendering
- GL-водопад переведён на общий LUT/AGC-уровни базового класса
  (убраны дубли ColorForDB/UpdateLevels)
- явные glViewport в GL-вьюхах умножаются на PixelScale (HiDPI),
  текстура водопада — в физической ширине

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 16:11:58 +03:00
co-authored by Claude Fable 5
parent 4733ddab58
commit 092a202f5f
10 changed files with 397 additions and 263 deletions
+194 -95
View File
@@ -15,6 +15,16 @@ uses
Classes, SysUtils, Graphics, ExtCtrls, Controls, Math,
LCLIntf, LCLType, AppTheme;
const
// Максимум точек строки водопада; держать в синхроне с
// WDSPEngine.SPECTRUM_PIXELS.
WF_MAX_PIXELS = 4096;
// Палитры водопада (индексы совпадают с настройкой wf_palette)
WF_PAL_CLASSIC = 0; // Thetis enhanced (тёмная) / пастельная (светлая тема)
WF_PAL_INFERNO = 1;
WF_PAL_TURBO = 2;
type
TWaterfallView = class
protected
@@ -24,11 +34,16 @@ type
FWfBitmapH: Integer;
FWfRowBuf: array of LongWord; // одна строка (новейшая), нативный порядок
FWfHead: Integer; // физ. строка bitmap с новейшей линией
FWfLut: array[0..511] of LongWord;
FWfLut: array[0..511] of LongWord; // нативный порядок байт (CPU bitmap)
FWfLutARGB: array[0..511] of LongWord; // логический $AARRGGBB (GL-путь)
FWfLutLow: Double;
FWfLutHigh: Double;
FWfLutLight: Boolean;
FWaterfallBuf: array[0..1023] of Single;
FWfLutPalette: Integer;
FWfLutGamma: Double;
FWfPalette: Integer;
FWfGamma: Double;
FWaterfallBuf: array[0..WF_MAX_PIXELS-1] of Single;
FWaterfallBufCount: Integer;
FWaterfallDirty: Boolean;
FWfFrameCounter: Integer;
@@ -53,6 +68,12 @@ type
procedure DrawMarkerLine(C: TCanvas; W, H: Integer);
procedure BuildWfLut(WfLow, WfHigh: Double);
function LutNeedsRebuild(WfLow, WfHigh: Double): Boolean;
function PaletteColor(T: Double): LongWord; // t∈[0..1] → $AARRGGBB
procedure SetWfPalette(V: Integer);
procedure SetWfGamma(V: Double);
procedure UpdateAutoLevels; // гистограммный AGC → FWfLow/FWfHigh
procedure GetEffectiveLevels(out Lo, Hi: Double);
public
constructor Create;
destructor Destroy; override;
@@ -72,6 +93,8 @@ type
property MarkerActive: Boolean read FMarkerActive write FMarkerActive;
property MarkerX: Integer read FMarkerX write FMarkerX;
property PbWaterfall: TControl write FPbWaterfall;
property WfPalette: Integer read FWfPalette write SetWfPalette;
property WfGamma: Double read FWfGamma write SetWfGamma;
procedure SetWaterfallData(const Pixels: array of Single; Count: Integer); virtual;
procedure DrawWaterfall; virtual;
@@ -97,29 +120,29 @@ begin
Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]);
end;
function WaterfallEnhancedColorThetis(ValueDB, LowDB, HighDB: Double): LongWord;
// Классическая тёмная палитра (Thetis enhanced), T∈[0..1]
function ClassicDarkColor(T: Double): LongWord;
var
Overall, Local: Double;
Local: Double;
R, G, B: Integer;
begin
if ValueDB <= LowDB then begin Result := $FF000000; Exit; end;
if ValueDB >= HighDB then begin Result := ($FF shl 24) or (255 shl 16) or (124 shl 8) or 192; Exit; end;
Overall := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB);
if Overall < (2.0 / 9.0) then
begin Local := Overall / (2.0/9.0); R := 0; G := 0; B := Round(Local * 255.0); end
else if Overall < (3.0 / 9.0) then
begin Local := (Overall - 2.0/9.0) / (1.0/9.0); R := 0; G := Round(Local*255.0); B := 255; end
else if Overall < (4.0 / 9.0) then
begin Local := (Overall - 3.0/9.0) / (1.0/9.0); R := 0; G := 255; B := Round((1.0-Local)*255.0); end
else if Overall < (5.0 / 9.0) then
begin Local := (Overall - 4.0/9.0) / (1.0/9.0); R := Round(Local*255.0); G := 255; B := 0; end
else if Overall < (7.0 / 9.0) then
begin Local := (Overall - 5.0/9.0) / (2.0/9.0); R := 255; G := Round((1.0-Local)*255.0); B := 0; end
else if Overall < (8.0 / 9.0) then
begin Local := (Overall - 7.0/9.0) / (1.0/9.0); R := 255; G := 0; B := Round(Local*255.0); end
if T <= 0.0 then begin Result := $FF000000; Exit; end;
if T >= 1.0 then begin Result := ($FF shl 24) or (255 shl 16) or (124 shl 8) or 192; Exit; end;
if T < (2.0 / 9.0) then
begin Local := T / (2.0/9.0); R := 0; G := 0; B := Round(Local * 255.0); end
else if T < (3.0 / 9.0) then
begin Local := (T - 2.0/9.0) / (1.0/9.0); R := 0; G := Round(Local*255.0); B := 255; end
else if T < (4.0 / 9.0) then
begin Local := (T - 3.0/9.0) / (1.0/9.0); R := 0; G := 255; B := Round((1.0-Local)*255.0); end
else if T < (5.0 / 9.0) then
begin Local := (T - 4.0/9.0) / (1.0/9.0); R := Round(Local*255.0); G := 255; B := 0; end
else if T < (7.0 / 9.0) then
begin Local := (T - 5.0/9.0) / (2.0/9.0); R := 255; G := Round((1.0-Local)*255.0); B := 0; end
else if T < (8.0 / 9.0) then
begin Local := (T - 7.0/9.0) / (1.0/9.0); R := 255; G := 0; B := Round(Local*255.0); end
else
begin
Local := (Overall - 8.0/9.0) / (1.0/9.0);
Local := (T - 8.0/9.0) / (1.0/9.0);
R := Round((0.75 + 0.25 * (1.0 - Local)) * 255.0);
G := Round(Local * 255.0 * 0.5);
B := 255;
@@ -130,28 +153,48 @@ begin
Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B;
end;
function WaterfallLightTheme(ValueDB, LowDB, HighDB: Double): LongWord;
const
NSTOPS = 8;
SR: array[0..NSTOPS-1] of Integer = (224, 160, 20, 0, 0, 220, 255, 255);
SG: array[0..NSTOPS-1] of Integer = (230, 185, 80, 185, 200, 210, 90, 20);
SB: array[0..NSTOPS-1] of Integer = (232, 210, 200, 210, 80, 0, 0, 20);
// Линейная интерполяция по таблице стопов, T∈[0..1]
function GradientColor(const SR, SG, SB: array of Integer; T: Double): LongWord;
var
T: Double;
Seg: Integer;
R, G, B: Integer;
F: Double;
Seg, N, R, G, B: Integer;
begin
if ValueDB <= LowDB then begin Result := $FFE0E6E8; Exit; end;
if ValueDB >= HighDB then begin Result := ($FF shl 24) or (SR[NSTOPS-1] shl 16) or (SG[NSTOPS-1] shl 8) or SB[NSTOPS-1]; Exit; end;
T := (ValueDB - LowDB) / Max(1E-9, HighDB - LowDB) * (NSTOPS - 1);
Seg := Min(NSTOPS - 2, Trunc(T));
T := T - Seg;
R := EnsureRange(Round(SR[Seg] * (1.0 - T) + SR[Seg+1] * T), 0, 255);
G := EnsureRange(Round(SG[Seg] * (1.0 - T) + SG[Seg+1] * T), 0, 255);
B := EnsureRange(Round(SB[Seg] * (1.0 - T) + SB[Seg+1] * T), 0, 255);
N := Length(SR);
if T <= 0.0 then
begin
Result := ($FF shl 24) or (SR[0] shl 16) or (SG[0] shl 8) or SB[0];
Exit;
end;
if T >= 1.0 then
begin
Result := ($FF shl 24) or (SR[N-1] shl 16) or (SG[N-1] shl 8) or SB[N-1];
Exit;
end;
F := T * (N - 1);
Seg := Min(N - 2, Trunc(F));
F := F - Seg;
R := EnsureRange(Round(SR[Seg] * (1.0 - F) + SR[Seg+1] * F), 0, 255);
G := EnsureRange(Round(SG[Seg] * (1.0 - F) + SG[Seg+1] * F), 0, 255);
B := EnsureRange(Round(SB[Seg] * (1.0 - F) + SB[Seg+1] * F), 0, 255);
Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B;
end;
const
// Классическая палитра светлой темы (пастель, фон E0E6E8)
LIGHT_R: array[0..7] of Integer = (224, 160, 20, 0, 0, 220, 255, 255);
LIGHT_G: array[0..7] of Integer = (230, 185, 80, 185, 200, 210, 90, 20);
LIGHT_B: array[0..7] of Integer = (232, 210, 200, 210, 80, 0, 0, 20);
// Inferno (matplotlib), перцептуально-равномерная, 11 опорных точек
INF_R: array[0..10] of Integer = ( 0, 22, 66, 106, 147, 188, 221, 243, 252, 246, 252);
INF_G: array[0..10] of Integer = ( 0, 11, 10, 23, 38, 55, 81, 120, 165, 215, 255);
INF_B: array[0..10] of Integer = ( 4, 57, 104, 110, 103, 84, 58, 25, 10, 70, 164);
// Turbo (Google), улучшенный jet без артефактов яркости, 9 опорных точек
TRB_R: array[0..8] of Integer = ( 48, 70, 24, 97, 164, 249, 247, 202, 122);
TRB_G: array[0..8] of Integer = ( 18, 118, 221, 252, 252, 163, 94, 33, 4);
TRB_B: array[0..8] of Integer = ( 59, 232, 194, 108, 60, 63, 33, 6, 3);
// Преобразует логический $AARRGGBB в нативный для ScanLine порядок байт.
// DARWIN (Cocoa): в памяти [A,R,G,B]; прочие (Qt6/Win32): [B,G,R,A].
// Платформы little-endian, поэтому байт 0 — младший байт LongWord.
@@ -195,9 +238,13 @@ begin
FTheme := DarkTheme;
FLightTheme := False;
FSpanHz := 192000;
FWfPalette := WF_PAL_CLASSIC;
FWfGamma := 1.0;
FWfLutLow := 1.0; // невалидно → LUT перестроится при первом кадре
FWfLutHigh := 0.0;
FWfLutLight := False;
FWfLutPalette := -1;
FWfLutGamma := 0.0;
ResetWfBuf;
end;
@@ -232,16 +279,34 @@ end;
procedure TWaterfallView.ResetWfBuf;
var i: Integer;
begin
for i := 0 to 1023 do FWaterfallBuf[i] := -130.0;
for i := 0 to High(FWaterfallBuf) do FWaterfallBuf[i] := -130.0;
FWfHigh := FWfManualHigh;
FWfLow := FWfManualLow;
FWaterfallDirty := True;
end;
procedure TWaterfallView.SetWfPalette(V: Integer);
begin
V := EnsureRange(V, WF_PAL_CLASSIC, WF_PAL_TURBO);
if V = FWfPalette then Exit;
FWfPalette := V;
FWfLutPalette := -1; // LUT перестроится на следующем кадре
FWaterfallDirty := True;
end;
procedure TWaterfallView.SetWfGamma(V: Double);
begin
V := EnsureRange(V, 0.3, 3.0);
if Abs(V - FWfGamma) < 1E-6 then Exit;
FWfGamma := V;
FWfLutGamma := 0.0; // LUT перестроится на следующем кадре
FWaterfallDirty := True;
end;
procedure TWaterfallView.SetWaterfallData(const Pixels: array of Single; Count: Integer);
var i, N: Integer;
begin
N := Min(Count, 1024);
N := Min(Count, WF_MAX_PIXELS);
for i := 0 to N - 1 do FWaterfallBuf[i] := Pixels[i];
FWaterfallBufCount := N;
Inc(FWfFrameCounter);
@@ -279,58 +344,71 @@ begin
C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl);
end;
// Цвет палитры для нормированного уровня T∈[0..1] (без гаммы — она в LUT)
function TWaterfallView.PaletteColor(T: Double): LongWord;
begin
case FWfPalette of
WF_PAL_INFERNO: Result := GradientColor(INF_R, INF_G, INF_B, T);
WF_PAL_TURBO: Result := GradientColor(TRB_R, TRB_G, TRB_B, T);
else
if FLightTheme then
Result := GradientColor(LIGHT_R, LIGHT_G, LIGHT_B, T)
else
Result := ClassicDarkColor(T);
end;
end;
function TWaterfallView.LutNeedsRebuild(WfLow, WfHigh: Double): Boolean;
begin
Result := (FWfLutLight <> FLightTheme) or
(FWfLutPalette <> FWfPalette) or
(Abs(FWfLutGamma - FWfGamma) > 1E-6) or
(Abs(WfLow - FWfLutLow) > 0.05) or
(Abs(WfHigh - FWfLutHigh) > 0.05);
end;
// Перестраивает палитру dB→цвет (512 точек) один раз на кадр вместо
// вычисления цвета с плавающей точкой на каждый пиксель.
procedure TWaterfallView.BuildWfLut(WfLow, WfHigh: Double);
var
i: Integer;
dB, Range: Double;
T: Double;
C: LongWord;
begin
Range := WfHigh - WfLow;
if Range < 1E-6 then Range := 1E-6;
for i := 0 to High(FWfLut) do
begin
dB := WfLow + (i / High(FWfLut)) * Range;
if FLightTheme then
C := WaterfallLightTheme(dB, WfLow, WfHigh)
else
C := WaterfallEnhancedColorThetis(dB, WfLow, WfHigh);
FWfLut[i] := NativePixel(C);
T := i / High(FWfLut);
// Гамма <1 вытягивает слабые сигналы из шума, >1 притемняет
if Abs(FWfGamma - 1.0) > 1E-6 then T := Power(T, FWfGamma);
C := PaletteColor(T);
FWfLutARGB[i] := C;
FWfLut[i] := NativePixel(C);
end;
FWfLutLow := WfLow;
FWfLutHigh := WfHigh;
FWfLutLight := FLightTheme;
FWfLutLow := WfLow;
FWfLutHigh := WfHigh;
FWfLutLight := FLightTheme;
FWfLutPalette := FWfPalette;
FWfLutGamma := FWfGamma;
end;
// ────────────────────────────────────────────────────────────────────────────
// DrawWaterfall
// ────────────────────────────────────────────────────────────────────────────
procedure TWaterfallView.DrawWaterfall;
// Гистограммный AGC: оценивает шумовой пол (30-й перцентиль) и верх сигналов
// (98-й), плавно подтягивает FWfLow/FWfHigh. Общий для CPU- и GL-пути.
procedure TWaterfallView.UpdateAutoLevels;
var
W, H, X: Integer;
dB, frac, WatSrcF: Double;
WatS0, WatS1: Integer;
X, SrcCount, LowTargetCount, HighTargetCount, CumCount, HistIdx: Integer;
Hist: array[0..191] of Integer;
HistMinDB, HistStepDB, NoiseFloorDB, SignalTopDB: Double;
TargetLow, TargetHigh: Double;
HistMinDB, HistMaxDB, HistStepDB: Double;
NoiseFloorDB, SignalTopDB: Double;
CumCount, LowTargetCount, HighTargetCount, HistIdx: Integer;
WfHigh, WfLow, LutScale, Step: Double;
Row, Idx: Integer; RowPtr: PByte;
Pal: LongWord; Hist: array[0..191] of Integer; SrcCount: Integer;
const
ALPHA_HIGH = 0.10; ALPHA_LOW = 0.08;
WF_AUTO_OFFSET = -4.0; WF_MIN_RANGE = 48.0; WF_MAX_RANGE = 62.0;
HIST_MAX_DB = 22.0;
begin
if FWaterfallBitmap = nil then Exit;
W := FWaterfallBitmap.Width; H := FWaterfallBitmap.Height;
if (W <= 0) or (H <= 0) then Exit;
SrcCount := EnsureRange(FWaterfallBufCount, 2, 1024);
if not FWfAGCEnabled then Exit;
SrcCount := EnsureRange(FWaterfallBufCount, 2, WF_MAX_PIXELS);
FillChar(Hist, SizeOf(Hist), 0);
HistMinDB := -170.0; HistMaxDB := 22.0;
HistStepDB := (HistMaxDB - HistMinDB) / Length(Hist);
HistMinDB := -170.0;
HistStepDB := (HIST_MAX_DB - HistMinDB) / Length(Hist);
for X := 0 to SrcCount - 1 do
begin
HistIdx := EnsureRange(Trunc((FWaterfallBuf[X] - HistMinDB) / HistStepDB), 0, High(Hist));
@@ -342,41 +420,62 @@ begin
CumCount := 0; NoiseFloorDB := FWfLow; SignalTopDB := FWfHigh;
for HistIdx := 0 to High(Hist) do
begin
CumCount := CumCount + Hist[HistIdx];
Inc(CumCount, Hist[HistIdx]);
if CumCount >= LowTargetCount then
begin NoiseFloorDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; Break; end;
end;
CumCount := 0;
for HistIdx := 0 to High(Hist) do
begin
CumCount := CumCount + Hist[HistIdx];
Inc(CumCount, Hist[HistIdx]);
if CumCount >= HighTargetCount then
begin SignalTopDB := HistMinDB + (HistIdx + 0.5) * HistStepDB; Break; end;
end;
if FWfAGCEnabled then
begin
TargetLow := NoiseFloorDB + WF_AUTO_OFFSET + FWfAGCOffset;
if FWfNFEnabled then
TargetHigh := Max(TargetLow + WF_MIN_RANGE, SignalTopDB + 6.0)
else
TargetHigh := TargetLow + 52.0;
if TargetHigh > TargetLow + WF_MAX_RANGE then TargetHigh := TargetLow + WF_MAX_RANGE;
FWfLow := FWfLow + ALPHA_LOW * (TargetLow - FWfLow);
FWfHigh := FWfHigh + ALPHA_HIGH * (TargetHigh - FWfHigh);
end;
WfHigh := FWfHigh; WfLow := FWfLow;
if not FWfAGCEnabled then begin WfHigh := FWfManualHigh; WfLow := FWfManualLow; end;
if WfHigh < WfLow + 40.0 then WfHigh := WfLow + 40.0;
if WfHigh > 0.0 then WfHigh := 0.0;
if WfLow < -160 then WfLow := -160;
TargetLow := NoiseFloorDB + WF_AUTO_OFFSET + FWfAGCOffset;
if FWfNFEnabled then
TargetHigh := Max(TargetLow + WF_MIN_RANGE, SignalTopDB + 6.0)
else
TargetHigh := TargetLow + 52.0;
if TargetHigh > TargetLow + WF_MAX_RANGE then TargetHigh := TargetLow + WF_MAX_RANGE;
FWfLow := FWfLow + ALPHA_LOW * (TargetLow - FWfLow);
FWfHigh := FWfHigh + ALPHA_HIGH * (TargetHigh - FWfHigh);
end;
procedure TWaterfallView.GetEffectiveLevels(out Lo, Hi: Double);
begin
if FWfAGCEnabled then begin Lo := FWfLow; Hi := FWfHigh; end
else begin Lo := FWfManualLow; Hi := FWfManualHigh; end;
if Hi < Lo + 40.0 then Hi := Lo + 40.0;
if Hi > 0.0 then Hi := 0.0;
if Lo < -160 then Lo := -160;
end;
// ────────────────────────────────────────────────────────────────────────────
// DrawWaterfall
// ────────────────────────────────────────────────────────────────────────────
procedure TWaterfallView.DrawWaterfall;
var
W, H, X: Integer;
dB, frac, WatSrcF: Double;
WatS0, WatS1: Integer;
WfHigh, WfLow, LutScale, Step: Double;
Row, Idx: Integer; RowPtr: PByte;
Pal: LongWord; SrcCount: Integer;
begin
if FWaterfallBitmap = nil then Exit;
W := FWaterfallBitmap.Width; H := FWaterfallBitmap.Height;
if (W <= 0) or (H <= 0) then Exit;
SrcCount := EnsureRange(FWaterfallBufCount, 2, WF_MAX_PIXELS);
UpdateAutoLevels;
GetEffectiveLevels(WfLow, WfHigh);
LutScale := High(FWfLut) / (WfHigh - WfLow);
// Палитра зависит только от WfLow/WfHigh/темы — перестраиваем лишь при
// ощутимом изменении (AGC сдвигает границы медленно).
if (FWfLutLight <> FLightTheme) or
(Abs(WfLow - FWfLutLow) > 0.05) or
(Abs(WfHigh - FWfLutHigh) > 0.05) then
// Палитра зависит только от WfLow/WfHigh/темы/палитры/гаммы — перестраиваем
// лишь при ощутимом изменении (AGC сдвигает границы медленно).
if LutNeedsRebuild(WfLow, WfHigh) then
BuildWfLut(WfLow, WfHigh);
if (FWfBitmapW <> W) or (FWfBitmapH <> H) then