Files
ewsdr/WaterfallView.pas
Uladzimir KarpenkaandClaude Opus 4.8 55d5bf01cf Optimize CPU waterfall/spectrum rendering
WaterfallView: drop per-frame TLazIntfImage + LoadFromIntfImage (device
bitmap recreation + full pixel-format conversion every frame). Write
pixels directly into the bitmap via BeginUpdate/ScanLine like
SpectrumView already does, with platform-native byte order (ARGB on
Cocoa, BGRA elsewhere).

Replace per-pixel float colorization with a 512-entry dB->color LUT
rebuilt only when WfLow/WfHigh/theme change beyond a small threshold.

Turn the bitmap itself into a ring buffer: each frame builds a single
new row and writes one ScanLine; scrolling is done at paint time via two
CopyRect calls. Removes both full-frame passes per frame (image memmove
+ buffer->bitmap copy), cutting per-frame cost from O(W*H) to O(W). TX
mode preserved by leaving out-of-span columns untouched in the row buf.

SpectrumView: rewrite the per-frame alpha-restore pass from a strided
per-byte loop to a sequential per-dword OR with the alpha mask
(bit-identical output, sequential memory access).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:21:40 +03:00

484 lines
19 KiB
ObjectPascal

unit WaterfallView;
{
WaterfallView.pas — рендеринг водопада.
TWaterfallView — изолированный класс, не зависит от SpectrumView.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, Graphics, ExtCtrls, Controls, Math,
LCLIntf, LCLType, AppTheme;
type
TWaterfallView = class
protected
FWaterfallBitmap: TBitmap;
FWfBitmap: TBitmap;
FWfBitmapW: Integer;
FWfBitmapH: Integer;
FWfRowBuf: array of LongWord; // одна строка (новейшая), нативный порядок
FWfHead: Integer; // физ. строка bitmap с новейшей линией
FWfLut: array[0..511] of LongWord;
FWfLutLow: Double;
FWfLutHigh: Double;
FWfLutLight: Boolean;
FWaterfallBuf: array[0..1023] of Single;
FWaterfallBufCount: Integer;
FWaterfallDirty: Boolean;
FWfFrameCounter: Integer;
FWfFrameInterval: Integer;
FWfAGCEnabled: Boolean;
FWfNFEnabled: Boolean;
FWfManualHigh: Double;
FWfManualLow: Double;
FWfAGCOffset: Double;
FWfHigh: Double;
FWfLow: Double;
FCenterFreq: Double;
FSpanHz: Double;
FTXMode: Boolean;
FTXFreq: Double;
FTXSpanHz: Double;
FMarkerActive: Boolean;
FMarkerX: Integer;
FTheme: TAppTheme;
FLightTheme: Boolean;
FPbWaterfall: TControl;
procedure DrawMarkerLine(C: TCanvas; W, H: Integer);
procedure BuildWfLut(WfLow, WfHigh: Double);
public
constructor Create;
destructor Destroy; override;
property CenterFreq: Double read FCenterFreq write FCenterFreq;
property SpanHz: Double read FSpanHz write FSpanHz;
property TXMode: Boolean read FTXMode write FTXMode;
property TXFreq: Double read FTXFreq write FTXFreq;
property TXSpanHz: Double read FTXSpanHz write FTXSpanHz;
property WfAGCEnabled: Boolean read FWfAGCEnabled write FWfAGCEnabled;
property WfNFEnabled: Boolean read FWfNFEnabled write FWfNFEnabled;
property WfManualHigh: Double read FWfManualHigh write FWfManualHigh;
property WfManualLow: Double read FWfManualLow write FWfManualLow;
property WfAGCOffset: Double read FWfAGCOffset write FWfAGCOffset;
property WaterfallDirty: Boolean read FWaterfallDirty write FWaterfallDirty;
property WfFrameInterval: Integer read FWfFrameInterval write FWfFrameInterval;
property MarkerActive: Boolean read FMarkerActive write FMarkerActive;
property MarkerX: Integer read FMarkerX write FMarkerX;
property PbWaterfall: TControl write FPbWaterfall;
procedure SetWaterfallData(const Pixels: array of Single; Count: Integer); virtual;
procedure DrawWaterfall; virtual;
procedure PaintWaterfall(Sender: TObject); virtual;
procedure SetWaterfallBitmapSize(W, H: Integer); virtual;
procedure ResetWfAvgBuf; virtual;
procedure ResetWfBuf; virtual;
procedure SetTheme(const T: TAppTheme); virtual;
end;
implementation
// ════════════════════════════════════════════════════════════════════════════
// Вспомогательные функции
// ════════════════════════════════════════════════════════════════════════════
function FormatFreqWF(Hz: Double): string;
var Mhz: Int64; KHz, Rest: Integer;
begin
Mhz := Trunc(Hz / 1000000);
KHz := Trunc((Hz - Mhz * 1000000) / 1000);
Rest := Trunc(Hz) mod 1000;
Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]);
end;
function WaterfallEnhancedColorThetis(ValueDB, LowDB, HighDB: Double): LongWord;
var
Overall, 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
else
begin
Local := (Overall - 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;
end;
R := EnsureRange(R, 0, 255);
G := EnsureRange(G, 0, 255);
B := EnsureRange(B, 0, 255);
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);
var
T: Double;
Seg: Integer;
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);
Result := ($FF shl 24) or (R shl 16) or (G shl 8) or B;
end;
// Преобразует логический $AARRGGBB в нативный для ScanLine порядок байт.
// DARWIN (Cocoa): в памяти [A,R,G,B]; прочие (Qt6/Win32): [B,G,R,A].
// Платформы little-endian, поэтому байт 0 — младший байт LongWord.
function NativePixel(ARGB: LongWord): LongWord; inline;
{$IFDEF DARWIN}
begin
Result := (ARGB shr 24) // A → байт 0
or (((ARGB shr 16) and $FF) shl 8) // R → байт 1
or (((ARGB shr 8) and $FF) shl 16) // G → байт 2
or ((ARGB and $FF) shl 24); // B → байт 3
end;
{$ELSE}
begin
Result := ARGB; // $AARRGGBB на LE уже даёт в памяти [B,G,R,A]
end;
{$ENDIF}
// ════════════════════════════════════════════════════════════════════════════
// TWaterfallView
// ════════════════════════════════════════════════════════════════════════════
constructor TWaterfallView.Create;
begin
inherited Create;
FWaterfallBitmap := TBitmap.Create;
FWfBitmap := TBitmap.Create;
FWfBitmap.PixelFormat := pf32bit;
FWfBitmapW := 0;
FWfBitmapH := 0;
FWfManualHigh := -80.0;
FWfManualLow := -130.0;
FWfAGCOffset := 0.0;
FWfHigh := -80.0;
FWfLow := -130.0;
FWfAGCEnabled := True;
FWfNFEnabled := False;
FWaterfallBufCount := 1024;
FWfFrameInterval := 2;
FWfFrameCounter := 0;
FWaterfallDirty := True;
FTheme := DarkTheme;
FLightTheme := False;
FSpanHz := 192000;
FWfLutLow := 1.0; // невалидно → LUT перестроится при первом кадре
FWfLutHigh := 0.0;
FWfLutLight := False;
ResetWfBuf;
end;
destructor TWaterfallView.Destroy;
begin
FWaterfallBitmap.Free;
FWfBitmap.Free;
inherited;
end;
procedure TWaterfallView.SetTheme(const T: TAppTheme);
begin
FTheme := T;
FLightTheme := T.BG > TColor($00808080);
FWaterfallDirty := True;
// Сброс размера — чтобы следующий кадр перезаполнил фон новой темой
FWfBitmapW := 0;
FWfBitmapH := 0;
FWfLutLow := 1.0; // невалидно → LUT перестроится
FWfLutHigh := 0.0;
SetLength(FWfRowBuf, 0);
FWfHead := 0;
end;
procedure TWaterfallView.ResetWfAvgBuf;
begin
FWfHigh := FWfManualHigh;
FWfLow := FWfManualLow;
FWaterfallDirty := True;
end;
procedure TWaterfallView.ResetWfBuf;
var i: Integer;
begin
for i := 0 to 1023 do FWaterfallBuf[i] := -130.0;
FWfHigh := FWfManualHigh;
FWfLow := FWfManualLow;
FWaterfallDirty := True;
end;
procedure TWaterfallView.SetWaterfallData(const Pixels: array of Single; Count: Integer);
var i, N: Integer;
begin
N := Min(Count, 1024);
for i := 0 to N - 1 do FWaterfallBuf[i] := Pixels[i];
FWaterfallBufCount := N;
Inc(FWfFrameCounter);
if FWfFrameCounter >= Max(1, FWfFrameInterval) then
begin
FWfFrameCounter := 0;
FWaterfallDirty := True;
end;
end;
procedure TWaterfallView.SetWaterfallBitmapSize(W, H: Integer);
begin
FWaterfallBitmap.SetSize(W, H);
FWfBitmapW := 0;
FWfBitmapH := 0;
SetLength(FWfRowBuf, 0);
FWfHead := 0;
end;
procedure TWaterfallView.DrawMarkerLine(C: TCanvas; W, H: Integer);
var MX: Integer; MarkerFreq: Double; MarkerLbl: string;
begin
MX := Round(FMarkerX / 1000.0 * W);
if (MX < 0) or (MX >= W) then Exit;
MarkerFreq := (FCenterFreq - FSpanHz / 2) + FMarkerX / 1000.0 * FSpanHz;
MarkerLbl := FormatFreqWF(Round(MarkerFreq));
C.Pen.Color := TColor($004444FF);
C.Pen.Width := 1; C.Pen.Style := psSolid;
C.MoveTo(MX, 0); C.LineTo(MX, H);
C.Font.Color := TColor($004444FF);
C.Font.Size := 7; C.Font.Name := 'Courier New';
if MX + 4 + C.TextWidth(MarkerLbl) < W then
C.TextOut(MX + 4, 4, MarkerLbl)
else
C.TextOut(MX - 4 - C.TextWidth(MarkerLbl), 4, MarkerLbl);
end;
// Перестраивает палитру dB→цвет (512 точек) один раз на кадр вместо
// вычисления цвета с плавающей точкой на каждый пиксель.
procedure TWaterfallView.BuildWfLut(WfLow, WfHigh: Double);
var
i: Integer;
dB, Range: 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);
end;
FWfLutLow := WfLow;
FWfLutHigh := WfHigh;
FWfLutLight := FLightTheme;
end;
// ────────────────────────────────────────────────────────────────────────────
// DrawWaterfall
// ────────────────────────────────────────────────────────────────────────────
procedure TWaterfallView.DrawWaterfall;
var
W, H, X: Integer;
dB, frac, WatSrcF: Double;
WatS0, WatS1: Integer;
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;
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);
FillChar(Hist, SizeOf(Hist), 0);
HistMinDB := -170.0; HistMaxDB := 22.0;
HistStepDB := (HistMaxDB - HistMinDB) / Length(Hist);
for X := 0 to SrcCount - 1 do
begin
HistIdx := EnsureRange(Trunc((FWaterfallBuf[X] - HistMinDB) / HistStepDB), 0, High(Hist));
Inc(Hist[HistIdx]);
end;
LowTargetCount := Round(SrcCount * 0.30);
HighTargetCount := Round(SrcCount * 0.98);
CumCount := 0; NoiseFloorDB := FWfLow; SignalTopDB := FWfHigh;
for HistIdx := 0 to High(Hist) do
begin
CumCount := 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];
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;
LutScale := High(FWfLut) / (WfHigh - WfLow);
// Палитра зависит только от WfLow/WfHigh/темы — перестраиваем лишь при
// ощутимом изменении (AGC сдвигает границы медленно).
if (FWfLutLight <> FLightTheme) or
(Abs(WfLow - FWfLutLow) > 0.05) or
(Abs(WfHigh - FWfLutHigh) > 0.05) then
BuildWfLut(WfLow, WfHigh);
if (FWfBitmapW <> W) or (FWfBitmapH <> H) then
begin
FWfBitmapW := W; FWfBitmapH := H;
SetLength(FWfRowBuf, W);
Pal := NativePixel($FF000000
or ((LongWord(FTheme.Panel) and $FF) shl 16)
or (LongWord(FTheme.Panel) and $FF00)
or ((LongWord(FTheme.Panel) shr 16) and $FF));
FillDWord(FWfRowBuf[0], W, Pal);
FWfBitmap.SetSize(W, H);
FWfHead := 0;
// Заливаем всё кольцо фоном
FWfBitmap.BeginUpdate(False);
try
for Row := 0 to H - 1 do
begin
RowPtr := PByte(FWfBitmap.ScanLine[Row]);
if RowPtr <> nil then FillDWord(RowPtr^, W, Pal);
end;
finally
FWfBitmap.EndUpdate(False);
end;
end;
// Строим только новейшую строку в FWfRowBuf. В TX-режиме столбцы вне TX-полосы
// НЕ трогаем — FWfRowBuf хранит предыдущую верхнюю строку, поэтому
// «замороженные» участки остаются вертикально непрерывными, как и раньше.
Step := (SrcCount - 1.0) / Max(1, W - 1);
WatSrcF := 0.0;
for X := 0 to W - 1 do
begin
if FTXMode and (FTXSpanHz > 0) and (FSpanHz > 0) then
begin
dB := FCenterFreq - FSpanHz * 0.5 + X * FSpanHz / Max(1, W - 1) - FTXFreq;
if (dB < -FTXSpanHz * 0.5) or (dB > FTXSpanHz * 0.5) then
begin
WatSrcF := WatSrcF + Step;
Continue; // оставляем FWfRowBuf[X] без изменений
end;
frac := (dB + FTXSpanHz * 0.5) / FTXSpanHz * (SrcCount - 1);
WatS0 := Trunc(frac);
if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2;
WatS1 := WatS0 + 1;
frac := frac - WatS0;
dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac;
end else
begin
WatS0 := Trunc(WatSrcF);
if WatS0 > SrcCount - 2 then WatS0 := SrcCount - 2;
WatS1 := WatS0 + 1;
frac := WatSrcF - WatS0;
dB := FWaterfallBuf[WatS0] * (1.0 - frac) + FWaterfallBuf[WatS1] * frac;
end;
Idx := Round((dB - WfLow) * LutScale);
if Idx < 0 then Idx := 0 else if Idx > High(FWfLut) then Idx := High(FWfLut);
FWfRowBuf[X] := FWfLut[Idx];
WatSrcF := WatSrcF + Step;
end;
// Кольцевой сдвиг: новая верхняя строка занимает слот выше предыдущей.
// Полнокадровый Move изображения и полная перезаливка bitmap больше не нужны —
// пишем единственную строку, а прокрутка делается в PaintWaterfall (CopyRect).
FWfHead := (FWfHead - 1 + H) mod H;
FWfBitmap.BeginUpdate(False);
try
RowPtr := PByte(FWfBitmap.ScanLine[FWfHead]);
if RowPtr <> nil then Move(FWfRowBuf[0], RowPtr^, W * 4);
finally
FWfBitmap.EndUpdate(False);
end;
end;
// ────────────────────────────────────────────────────────────────────────────
// PaintWaterfall
// ────────────────────────────────────────────────────────────────────────────
procedure TWaterfallView.PaintWaterfall(Sender: TObject);
var PB: TPaintBox; W, H, Top: Integer;
begin
PB := TPaintBox(Sender);
W := PB.Width; H := PB.Height;
if (W <= 0) or (H <= 0) then Exit;
if (FWfBitmap <> nil) and (FWfBitmap.Width = W) and (FWfBitmap.Height = H)
and (Length(FWfRowBuf) = W) then
begin
// Кольцевой буфер: дисплейная строка 0 = физ. строка FWfHead.
// Верхний сегмент — строки FWfHead..H-1, нижний — 0..FWfHead-1.
Top := H - FWfHead;
PB.Canvas.CopyRect(Rect(0, 0, W, Top),
FWfBitmap.Canvas, Rect(0, FWfHead, W, H));
if FWfHead > 0 then
PB.Canvas.CopyRect(Rect(0, Top, W, H),
FWfBitmap.Canvas, Rect(0, 0, W, FWfHead));
end
else begin
PB.Canvas.Brush.Color := FTheme.Panel;
PB.Canvas.FillRect(Rect(0, 0, W, H));
end;
if FMarkerActive then DrawMarkerLine(PB.Canvas, W, H);
end;
end.