Спектрограммы

Введение

Мой проект r2cloud записывает сигнал пролетающего спутника на диск, а затем не спеша пытается извлечь из него данные и поделиться ими со всем миром. В большинстве случаев никаких данных получить не удаётся. Причины могут быть разными:

  • низкий пролёт спутника и, как следствие, слабый сигнал;
  • проблемы в приёмнике и/или антенне;
  • устаревшие параметры спутника (TLE);
  • спутник просто ничего не передаёт — либо нечего передавать, либо произошла ошибка, либо трансивер выключен.

Чтобы понять, в чём может быть проблема, удобно посмотреть на спектрограмму сигнала. Спектрограмма — это самый простой и при этом один из самых эффективных способов анализа.

Обычно спектрограмма представляет собой изображение. По оси X отложены частоты, по оси Y — время. Цветом кодируется мощность сигнала: синий или голубой — шум (сигнала нет), жёлтый — слабый сигнал, красный — сильный.

Такое представление позволяет быстро понять, был ли сигнал в определённое время на определённой частоте.

На изображении выше, например, хорошо видно:

  • три разных сигнала;
  • центральный сигнал — самый сильный;
  • все три сигнала смещаются справа налево. Это означает, что источник быстро движется, и частота меняется из-за эффекта Доплера;
  • сигналы появляются лишь периодически. Скорее всего, спутники передают телеметрию по таймеру.

Вычисление спектограммы

Чтобы построить такое изображение, сначала нужно определить, сколько герц будет приходиться на один пиксель:

int width = (int) (sampleRate / numHertzPerPixel);

Затем выполнить одномерное преобразование Фурье:

FloatFFT_1D fft = new FloatFFT_1D(width);
float[] complexBuf = new float[width * 2];
for (int i = 0; i < complexBuf.length; i += 2) {
	complexBuf[i] = source.readFloat();
	complexBuf[i + 1] = source.readFloat();
}
fft.complexForward(complexBuf);

Нормализовать результаты относительно ширины изображения:

float iNormalizationFactor = (float) 1 / width;
for (int i = 0, j = 0; i < complexBuf.length; i += 2, j++) {
	complexBuf[i] = complexBuf[i] * iNormalizationFactor;
	complexBuf[i + 1] = complexBuf[i + 1] * iNormalizationFactor;
}

Посчитать мощность:

for (int i = 0, j = 0; i < complexBuf.length; i += 2, j++) {
	tempResults[j] = (float) (10.0 * Math.log10((real * real) + (img * img) + 1e-20));
}

В итоге получается довольно длинное изображение. Чтобы этого избежать, можно принять один пиксель по высоте равным одной секунде и накапливать максимальное значение:

for (int j = 0; j < tempResults.length; j++) {
	tempResults[j] = Math.max(tempResults[j], (float) (10.0 * Math.log10((real * real) + (img * img) + 1e-20)));
}

Обычно середина изображения соответствует 0 Гц (baseband), поэтому две половинки нужно поменять местами:

for (int i = 0; i < length; i++) {
	float temp = tempResults[currentRow * width + i];
	tempResults[currentRow * width + i] = tempResults[currentRow * width + length + i];
	tempResults[currentRow * width + length + i] = temp;
}

После этого можно либо отнормировать результаты к минимальному и максимальному значениям, либо сразу создавать изображение:

BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
for (int i = 0; i < height; i++) {
	for (int j = 0; j < width; j++) {
		image.setRGB(j, i, convertGrayToRgb(tempResults[width * i + j]));
	}
}

Алгоритм простой и вполне достаточный для анализа сигналов. Однако у него есть несколько недостатков, которые хотелось бы исправить:

  • PNG хранит 3 байта на пиксель, хотя достаточно одного. Из-за этого спектрограмма 10-минутного наблюдения занимает около 1,2 МБ.
  • Нет доступа к исходным данным. Поэтому нельзя отмасштабировать спектрограмму или корректно сравнить результаты с разных SDR-устройств.

Решение

Решение лежит на поверхности:

  • генерировать и хранить чёрно-белое изображение;
  • при показе конвертировать его в цветное.

Java отлично поддерживает чёрно-белые типы изображений, но есть важный нюанс. Значения пикселей нужно выставлять напрямую, в обход ColorModel. Иначе пиксели будут “улучшены” для более приятного восприятия человеком. В нашем случае спектрограмма — инструмент анализа данных, и у неё другая цель.

BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);
WritableRaster r = image.getRaster();
int[] pixelArray = new int[1];
for (int i = 0; i < height; i++) {
	for (int j = 0; j < width; j++) {
		int pixel = (int) (tempResults[i * width + j] + 255);
		pixelArray[0] = (pixel & 0xFF);
		r.setPixel(j, height - i - 1, pixelArray);
	}
}

При показе на странице изображение дополнительно конвертируется. Делается это примерно так (псевдокод):

vm.$http.get(this.src, { baseURL: null, responseType: 'arraybuffer' }).then(function (response) {
  vm.canvas = document.getElementById('canvas');
  const ctx = vm.canvas.getContext('2d');
  const header = vm.parsePngHeader(response.data);
  const img = new Image();
  img.onload = () => {
    vm.canvas.width = img.width;
    vm.canvas.height = img.height;
    ctx.drawImage(img, 0, 0);
    const imageData = ctx.getImageData(0, 0, img.width, img.height);
    const data = imageData.data;
		  
    for (let j = 0; j < data.length; j += 4) {
      let rgb = vm.convertGrayToRgb(data[j] - 255);
      data[j] = rgb[0];
      data[j+1] = rgb[1];
      data[j+2] = rgb[2];
      data[j+3] = 0xFF;
    }
    ctx.putImageData(imageData, 0, 0);
  };
  img.src = URL.createObjectURL(new Blob([response.data], { type: 'image/png' }));
}).catch(function (error) {
  console.log(error)
})

Из интересных особенностей:

  • Чтобы получить доступ к чёрно-белым пикселям, изображение сначала нужно отрисовать.
  • Safari прогоняет пиксели через свою собственную ColorModel, поэтому они могут чуть-чуть отличаться от тех, что были записаны в Java. В других браузерах ситуация может быть немного лучше или хуже.

Приятный бонус отрисовки в браузере — возможность добавить интерактивность. Например, показывать время, частоту и мощность в текущем выделенном пикселе: