Безмежне поле для експериментів: як я перетворюю картинки на музику за допомогою Python

Оленка Пилипчак

Data Scientist (а за сумісництвом — фізик та хімік) Віктор Мурсія у своєму блозі на Medium пише, що музика була з ним завжди. Він слухає її кожного дня та грає на гітарі більше 20 років. Перед тим як розпочати свою академічну фізико-хімічну подорож, Віктор замислювався про музичну кар’єру. Передаємо йому слово.


І хоча я не обрав цей шлях, я насолоджуюсь музикою і можу робити круті речі з нею. Наприклад, я вирішив створити програму, яка б «програвала» зображення. Я бачив такі спроби, але, на мою думку, результати були не надто милозвучні.

Тут я покажу свою версію такої програми та результати. Якщо ви хочете переглянути повний код, ви можете знайти його в репозиторії GitHub. Я створив програму за допомогою Streamlit.

Основна ідея

Моя стратегія та хід думок були такі: 

  • зображення складаються з пікселів;
  • пікселі складаються з масивів чисел, які позначають колір;
  • колір описується за допомогою колірних просторів RGB, BGR або HSV;
  • колірний простір можна розділити на секції;
  • музичні гами поділяються на ноти через звукові інтервали;
  • звук — це вібрація, тому кожна нота пов’язана з частотою.

Таким чином підрозділ колірного простору може бути прив’язаний до конкретної ноти в музичній гамі. Тоді ця нота матиме частоту, пов’язану з нею.

Давайте спробуємо!

Використання колірного простору HSV

HSV — це колірний простір, який контролюється трьома значеннями: відтінок, насиченість та яскравість.

HSV-циліндр / Джерело: Wikipedia

Відтінок — це рівень, до якого подразник може бути описаний як схожий або відмінний від подразників, які описані як червоний, помаранчевий, жовтий, зелений, синій, фіолетовий. По суті, відтінок — це колір.

Насиченість визначають як барвистість області, оціненої пропорційно до її яскравості. Іншими словами, насиченість означає кількість білого у цьому кольорі.

Яскравість визначається як сприйняття, викликане яскравістю візуальної цілі. Іншими словами, насиченість означає означає кількість чорного у цьому кольорі.

Відтінкові значення основних кольорів:

  • помаранчевий — 0-44;
  • жовтий — 44-76;
  • зелений — 76-150;
  • синій — 150-260;
  • фіолетовий — 260-320;
  • червоний — 320-360.

Я працюватиму в колірному просторі HSV, оскільки він вже розділений, тому подальше відображення частот стає більш інтуїтивним. 

Ось приклад порівняння між просторами кольорів для зображення та кодом для їх створення:

#Need function that reads pixel hue value 
hsv = cv2.cvtColor(ori_img, cv2.COLOR_BGR2HSV)
#Plot the image
fig, axs = plt.subplots(1, 3, figsize = (15,15))
names = ['BGR','RGB','HSV']
imgs  = [ori_img, img, hsv]
i = 0
for elem in imgs:
    axs[i].title.set_text(names[i])
    axs[i].imshow(elem)
    axs[i].grid(False)
    i += 1
plt.show()

Колірні простори / Джерело: оригінальне зображення RGB від agsandrew. Авторські зображення — Victor Murcia

Вилучення каналу відтінку

Тепер, коли у нас є наше зображення в HSV, давайте витягнемо значення відтінку (H) з кожного пікселя. Це можна зробити за допомогою вкладеного циклу for по висоті та ширині зображення:

i=0 ; j=0
#Initialize array the will contain Hues for every pixel in image
hues = [] 
for i in range(height):
    for j in range(width):
        hue = hsv[i][j][0] #This is the hue value at pixel coordinate (i,j)
        hues.append(hue)

Тепер, коли у мене є масив, що містить значення H для кожного пікселя, я розміщу цей результат у pandas dataframe. Кожен рядок у dataframe є пікселем, і, отже, кожен стовпець міститиме інформацію про цей піксель. Я назву цей dataframe pixels_df.

Ось він:

Зараз dataframe складається з одного стовпця під назвою «відтінки», де кожен рядок представляє H-канал для кожного пікселя на зображенні, яке я завантажив.

Перетворення відтінків на частоти

Моя початкова ідея щодо перетворення значення відтінку на частоту передбачала відображення між заздалегідь визначеним набором частот і значенням H. Функція відображення показана нижче:

#Define frequencies that make up A-Harmonic Minor Scale
scale_freqs = [220.00, 246.94 ,261.63, 293.66, 329.63, 349.23, 415.30] 
def hue2freq(h,scale_freqs):
    thresholds = [26 , 52 , 78 , 104,  128 , 154 , 180]
    note = scale_freqs[0]
    if (h <= thresholds[0]):
         note = scale_freqs[0]
    elif (h > thresholds[0]) & (h <= thresholds[1]):
        note = scale_freqs[1]
    elif (h > thresholds[1]) & (h <= thresholds[2]):
        note = scale_freqs[2]
    elif (h > thresholds[2]) & (h <= thresholds[3]):
        note = scale_freqs[3]
    elif (h > thresholds[3]) & (h <= thresholds[4]):    
        note = scale_freqs[4]
    elif (h > thresholds[4]) & (h <= thresholds[5]):
        note = scale_freqs[5]
    elif (h > thresholds[5]) & (h <= thresholds[6]):
        note = scale_freqs[6]
    else:
        note = scale_freqs[0]
    
    return note

Функція приймає значення H і масив, що містить частоти для відображення H як inputs. У прикладі використовується масив під назвою scale_freqs для визначення частот. Частоти, що використовуються в scale_freqs, відповідають мінорній гармонічній гамі.

Потім визначається масив порогових значень для H. Цей масив можна використовувати для перетворення H у частоту зі scale_freqs за допомогою лямбда-функції:

pixels_df['notes'] = pixels_df.apply(lambda row : hue2freq(row['hues'],scale_freqs), axis = 1)

Перетворення масиву NumPy на аудіо

А тепер, коли я маю масив частот, я конвертую стовпець notes у масив numpy, що називається frequencies, оскільки потім я можу використати це для створення аудіофайлу, який можна відтворити.

Для цього я можу використати функцію wavfile.write, яка вбудована у scipy, і переконатися, що я використовую відповідне перетворення типу даних (для 1D-масивів це np.float32):

frequencies = pixels_df['notes'].to_numpy()

song = np.array([]) 
sr = 22050 # sample rate
T = 0.1    # 0.1 second duration
t = np.linspace(0, T, int(T*sr), endpoint=False) # time variable
#Make a song with numpy array :]
#nPixels = int(len(frequencies))#All pixels in image
nPixels = 60
for i in range(nPixels):  
    val = frequencies[i]
    note  = 0.5*np.sin(2*np.pi*val*t) #Represent each note as a sign wave
    song  = np.concatenate([song, note]) #Add notes into song array to make song
    
ipd.Audio(song, rate=sr) # load a NumPy array as audio

Послухайте пісню, яку я створив, використовуючи перші 60 пікселів із зображення нижче (я міг би спробувати використати всі 230 400 пікселів цього зображення, але тоді пісня триватиме кілька годин):

 

Це дуже гарно! Але я б ще трохи її вдосконалив.

Додаємо октави

Я вирішив додати ефект октав (тобто зробити так, щоб ноти звучали вище або нижче) у процес створення пісні. Октава, яка буде використана для певної ноти, обирається випадковим чином із масиву:

song = np.array([]) 
octaves = np.array([0.5,1,2])
sr = 22050 # sample rate
T = 0.1    # 0.1 second duration
t = np.linspace(0, T, int(T*sr), endpoint=False) # time variable
#Make a song with numpy array :]
#nPixels = int(len(frequencies))#All pixels in image
nPixels = 60
for i in range(nPixels):
    octave = random.choice(octaves)
    val =  octave * frequencies[i]
    note  = 0.5*np.sin(2*np.pi*val*t)
    song  = np.concatenate([song, note])
ipd.Audio(song, rate=sr) # load a NumPy array

Давайте послухаємо! 

 

Чудово! Тепер звучить цікавіше. Однак у нас є всі ці пікселі: спробуємо використати їх, обираючи частоти з випадкових пікселів?

song = np.array([]) 
octaves = np.array([1/2,1,2])
sr = 22050 # sample rate
T = 0.1    # 0.1 second duration
t = np.linspace(0, T, int(T*sr), endpoint=False) # time variable
#Make a song with numpy array :]
#nPixels = int(len(frequencies))#All pixels in image
nPixels = 60
for i in range(nPixels):
    octave = random.choice(octaves)
    val =  octave * random.choice(frequencies)
    note  = 0.5*np.sin(2*np.pi*val*t)
    song  = np.concatenate([song, note])
ipd.Audio(song, rate=sr) # load a NumPy array

 

Мені подобається: тепер це фактично генератор пісень, з яким можна бавитись скільки завгодно!

Я знаю, що це мем, але «це математичний рок?» 🙂

Створення інших шкал

Я показав, як можна створювати музику із зображень за допомогою ля-гармонічної мінорної гами. Але було б непогано отримати більше різноманітності у початковій ноті (тоніці) нашої гами та інших інтервалів, окрім тих, що визначені структурою гармонічного мінорного звукоряду. Це дозволить нашій програмі створювати більш різноманітні мелодії.

Для цього мені потрібний спосіб процедурної генерації частот для будь-якої тонічної ноти, яку ми хочемо використовувати. Кеті Хе написала чудову статтю, де є ретельне дослідження Python і музики. Я адаптував одну з її функцій для своєї роботи, щоб зіставляти ноти фортепіано з частотами, як показано нижче:

def get_piano_notes():   
    # White keys are in Uppercase and black keys (sharps) are in lowercase
    octave = ['C', 'c', 'D', 'd', 'E', 'F', 'f', 'G', 'g', 'A', 'a', 'B'] 
    base_freq = 440 #Frequency of Note A4
    keys = np.array([x+str(y) for y in range(0,9) for x in octave])
    # Trim to standard 88 keys
    start = np.where(keys == 'A0')[0][0]
    end = np.where(keys == 'C8')[0][0]
    keys = keys[start:end+1]
    
    note_freqs = dict(zip(keys, [2**((n+1-49)/12)*base_freq for n in range(len(keys))]))
    note_freqs[''] = 0.0 # stop
    return note_freqs

Ця функція слугуватиме відправною точкою для моєї процедури створення пісні/шкали, та її можна використовувати для створення словника, який відображає музичні ноти, що відповідають 88 клавішам стандартного піаніно, на частоти в одиницях Гц:

#Load note dictionary
note_freqs = get_piano_notes()

Потім нам потрібно визначити інтервали шкали в термінах тонів, щоб ми могли індексувати наші ноти:

#Define tones. Upper case are white keys in piano. Lower case are black keys
scale_intervals = ['A','a','B','C','c','D','d','E','F','f','G','g']

А тепер ми можемо знайти індекс нашої гами у попередньому списку тонів. Це потрібно, тому що я потім переіндексую список, щоб він починався з потрібної нам тоніки:

#Find index of desired key
index = scale_intervals.index(whichKey)

#Redefine scale interval so that scale intervals begins with whichKey
new_scale = scale_intervals[index:12] + scale_intervals[:index]

Після цього я можу визначити групу різних масивів шкали, де кожен елемент відповідає індексу з масиву повторного індексування, який я щойно зробив вище:

#Choose scale
if whichScale == 'AEOLIAN':
    scale = [0, 2, 3, 5, 7, 8, 10]
elif whichScale == 'BLUES':
    scale = [0, 2, 3, 4, 5, 7, 9, 10, 11]
elif whichScale == 'PHYRIGIAN':
    scale = [0, 1, 3, 5, 7, 8, 10]
elif whichScale == 'CHROMATIC':
    scale = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
elif whichScale == 'DORIAN':
    scale = [0, 2, 3, 5, 7, 9, 10]
elif whichScale == 'HARMONIC_MINOR':
    scale = [0, 2, 3, 5, 7, 8, 11]
elif whichScale == 'LYDIAN':
    scale = [0, 2, 4, 6, 7, 9, 11]
elif whichScale == 'MAJOR':
    scale = [0, 2, 4, 5, 7, 9, 11]
elif whichScale == 'MELODIC_MINOR':
    scale = [0, 2, 3, 5, 7, 8, 9, 10, 11]
elif whichScale == 'MINOR':    
    scale = [0, 2, 3, 5, 7, 8, 10]
elif whichScale == 'MIXOLYDIAN':     
    scale = [0, 2, 4, 5, 7, 9, 10]
elif whichScale == 'NATURAL_MINOR':   
    scale = [0, 2, 3, 5, 7, 8, 10]
elif whichScale == 'PENTATONIC':    
    scale = [0, 2, 4, 7, 9]
else:
    print('Invalid scale name')

Майже готово! Я також визначу тут інтервали для використання:

#Make harmony dictionary (i.e. fundamental, perfect fifth, major third, octave)
#unison           = U0 #semitone         = ST
#major second     = M2 #minor third      = m3 
#major third      = M3 #perfect fourth   = P4
#diatonic tritone = DT #perfect fifth    = P5
#minor sixth      = m6 #major sixth      = M6
#minor seventh    = m7 #major seventh    = M7
#octave           = O8
harmony_select = {'U0' : 1,
                      'ST' : 16/15,
                      'M2' : 9/8,
                      'm3' : 6/5,
                      'M3' : 5/4,
                      'P4' : 4/3,
                      'DT' : 45/32,
                      'P5' : 3/2,
                      'm6': 8/5,
                      'M6': 5/3,
                      'm7': 9/5,
                      'M7': 15/8,
                      'O8': 2
                     }

І тепер я можу взяти результати попередніх кроків і створити пісню!

#Get length of scale (i.e., how many notes in scale)
nNotes = len(scale)

#Initialize arrays
freqs = []
#harmony = []
#harmony_val = harmony_select[makeHarmony]
for i in range(nNotes):
    note = new_scale[scale[i]] + str(whichOctave)
    freqToAdd = note_freqs[note]
    freqs.append(freqToAdd)
    #harmony.append(harmony_val*freqToAdd)

Супер! Зараз у мене є чимало параметрів, які я можу використати, щоб створювати пісні. Я можу встановити тональність, октаву, гармонію, кількість пікселів, вибір пікселів випадковим чином і тривалість кожної ноти.

Давайте перевіримо це на кількох зображеннях. Частота дискретизації для всіх зображень становить 22050 Гц, якщо не зазначено інше.

Експеримент з піксельним мистецтвом

Я подумав, що можна використати піксельне мистецтво. Ось одна з пісень, які вийшли в результаті використання цього чудового твору @Matej ‘Retro’ Jan як вихідного зображення. Ця пісня була створена з використанням третьої октави як основи в тональності ля мажор. Досить мило, чи не так? 

Піксельні китайські гори / Джерело: Matej ‘Retro’ Jan

Пісня павича

Ця пісня створена з використанням E Dorian та третьої октави:

Джерело: Anna Om

Пісня води

Цю пісню було створено з використанням B Lydian та діапазону другої октави. Моя улюблена! Звучатиме дуже круто як гітарний риф.

Автор фото: John Salatas

Пісня Катерини

Я обожнюю свою кішку Катерину. Цю пісню створено з використанням гармонічного мінору А та діапазону третьої октави. Звучить дуже гарно, як на мене. 

Додаємо гармонію в пісні за допомогою 2D-масивів NumPy

Те, що я робив вище, дозволяє додати гармонії нашій пісні. Користувач може визначити, яку гармонію використовувати, а потім із цього виводиться правильний інтервал ноти за допомогою певного процесу.

Нижче я покажу приклад пісні, створеної із зображення та відповідної гармонії, що об’єднані single .wav file з допомогою двовимірного масиву numpy.

Згідно з документацією для scipy.io.wavfile.write, якщо я хочу записати 2D-масив у файл .wav, 2D-масив повинен мати розміри у формі (Nsamples, Nchannels).

Зверніть увагу, яка зараз форма нашого масиву (2, 264600). Це означає, що ми маємо Nchannels = 2 і Nsamples = 264600. Щоб переконатися, що наш масив numpy має правильну форму для scipy.io.wavfile.write, я спочатку транспоную масив. Пісня створена з використанням гармонічного мінору A#, діапазону другої октави та мінорної терційної гармонії.

Додавання ефектів до наших пісень за допомогою Pedalboard Library від Spotify

А ще я збираюся завантажити файли .wav і виконати деякі додаткові маніпуляції з ними за допомогою модуля pedalboard від Spotify. Ви можете прочитати більше про бібліотеку pedalboard тут і тут.

Спочатку я повторно оброблю «Пісню води», яку показав раніше, використовуючи Compressor, Gain, Chorus, Phaser, Reverb, and a Ladder Filter.

Ось результат:

Тепер я повторно оброблю пісню «Катерина», використовуючи Ladder Filter, Delay, Reverb і PitchShift:

 

Звучить гарнезно!

І ще пограюсь зо піснею, яка створена з пейзажу. Використаю LadderFilter, Delay, Reverb, Chorus, PitchShift і Phaser:

Використання Librosa для відображення інших музичних величин

Librosa — чудовий пакет, який дозволяє виконувати різноманітні операції над звуковими даними. Спробуйте його обов’язково! Тут я використовував його, щоб перетворювати частоти у Notes і Midi Numbers.

Файли цифрового інтерфейсу музичних інструментів (MIDI) використовуються як формат файлу, який можна підключати до різноманітних електронних музичних інструментів, комп’ютерів та інших аудіопристроїв.

Якщо ми збережемо пісню у цьому форматі, це дозволить іншим музикантам або програмістам використати її для експериментів.

Нижче я показую функції, які можна використовувати для відображення частот, які я згенерував, щоб отримати відповідні музичні ноти та midi_numbers для нашої пісні:

#Convert frequency to a note
catterina_df['notes'] = catterina_df.apply(lambda row : librosa.hz_to_note(row['frequencies']), 
                                           axis = 1)  
#Convert note to a midi number
catterina_df['midi_number'] = catterina_df.apply(lambda row : librosa.note_to_midi(row['notes']), 
                                                 axis = 1)

Створення MIDI з пісні

Тепер, коли я створив dataframe, що містить частоти, ноти та міді-номера, я можу створити з нього MIDI-файл. Потім я міг би використати цей MIDI-файл для створення нот для нашої пісні. 

Щоб створити MIDI-файл, я скористаюся пакетом midiutil. Цей пакет дозволяє створювати MIDI-файли з масиву MIDI-номерів. Ви можете налаштувати свій файл різними способами, налаштувавши гучність, темп і доріжки. Наразі я зроблю лише однодоріжковий MIDI -файл:

#Convert midi number column to a numpy array
midi_number = catterina_df['midi_number'].to_numpy()

degrees  = list(midi_number) # MIDI note number
track    = 0
channel  = 0
time     = 0   # In beats
duration = 1   # In beats
tempo    = 240  # In BPM
volume   = 100 # 0-127, as per the MIDI standard

MyMIDI = MIDIFile(1) # One track, defaults to format 1 (tempo track
                     # automatically created)
MyMIDI.addTempo(track,time, tempo)

for pitch in degrees:
    MyMIDI.addNote(track, channel, pitch, time, duration, volume)
    time = time + 1
with open("catterina.mid", "wb") as output_file:
    MyMIDI.writeFile(output_file)

Висновок

Я показав, як можна створювати музику із зображень, і як ці пісні можна експортувати у файли .wav для подальшої обробки. А ще — як за допомогою цього методу можна побудувати гармонії, і з цього створити більш складні, насичені та/або дивні гармонії. Тут просто безмежне поле для експерементів!

Якщо ви музикант, і вам не вистачає натхнення, спробуйте додати зображення в мій застосунок, і, можливо, ви отримаєте круту ідею, на яку можна спиратися. Ніколи не знаєш, звідки прийде натхнення! 

Автор: Віктор Мурсія

Текст адаптувала Євгенія Козловська

Останні статті

Айтівець Міноборони США понабирав кредитів і хотів продати рф секретну інформацію

32-річний розробник безпеки інформаційних систем Агентства національної безпеки Джарех Себастьян Далке отримав 22 роки в'язниці…

30.04.2024

Простий та дешевий. Українська Flytech запустила масове виробництво розвідувальних БПЛА ARES

Українська компанія Flytech представила розвідувальний безпілотний літальний апарат ARES. Основні його переваги — недорога ціна…

30.04.2024

Запрошуємо взяти участь у премії TechComms Award. Розкажіть про свій потужний PR-проєкт у сфері IT

MC.today разом з Асоціацією IT Ukraine і сервісом моніторингу та аналітики згадок у ЗМІ та…

30.04.2024

«Йдеться про потенціал мобілізації»: Україна не планує примусово повертати українців із ЄС

Україна не буде примусово повертати чоловіків призовного віку з-за кордону. Про це повідомила у Брюсселі…

30.04.2024

В ЗСУ з’явився жіночий підрозділ БПЛА — і вже можна проходити конкурсний відбір

В Збройних Силах України з'явився жіночий підрозділ з БПЛА. І вже проводиться конкурсний відбір до…

30.04.2024

GitHub на наступному тижні випустить Copilot Workplace — ШІ-помічника для розробників

GitHub анонсував Copilot Workspace, середовище розробки з використанням «агентів на базі Copilot». За задумкою, вони…

30.04.2024