Skip to content

Spectrum Peak Picking and Integration

Pick and integrate peaks on NMR, IR, and mass spectra read from JCAMP-DX or mzML files, with suggested peaks to accept or reject and one CSV row per peak.

New in v2.13.0

The spectrum display draws one spectrum per item in the browser, and the peak_picking scheme records the peaks an annotator picks, the regions the annotator integrates, and the suggested peaks the annotator accepts or rejects. Chemists and spectroscopists use the pair to build labeled spectra datasets, such as peak lists for NMR spectroscopy, band assignment in infrared spectroscopy, and peak annotation in mass spectra. Instructors collect each student's peak list for the same spectrum and grade the lists after export. Groups that run an automatic peak picker load the picker's peaks as suggestions, and annotators accept or reject each suggested peak.

Peak picking lists the position and intensity of each peak in a spectrum. In a 1H NMR spectrum, the integral of a multiplet is proportional to the number of protons that give the multiplet, and annotators integrate a multiplet by dragging across the multiplet.

JCAMP-DX is a text format for spectroscopic data, maintained by IUPAC. mzML is the XML format of the HUPO Proteomics Standards Initiative for mass spectrometry data, and one mzML file holds the spectra of one run.

  • Engine: Plotly 2.27.0 (MIT), committed with Potato and loaded only on pages with a spectrum. Plotly draws SVG, and the display needs no WebGL.
  • Formats: JCAMP-DX (.jdx, .dx, .jcamp), mzML (.mzML), and two-column text (.csv, .tsv, .txt, .xy). A spectrum can also be written into the item as two lists.
  • Long spectra: files are parsed on the server, and the browser receives at most max_points points (4,000 by default). Each bucket of the original spectrum keeps its lowest and highest point, and a narrow NMR line therefore keeps its full height. Zooming in fetches the original points for the visible range, and the server downsamples the range again only when the range holds more than max_points.
  • Axis conventions: chemical shift and wavenumber axes run from high to low, left to right. An NMR spectrum stored in Hz is converted to ppm with the file's .OBSERVE FREQUENCY.
  • Offline: spectrum files are read from the media directory on the Potato server. A spectrum named by URL is refused, and Potato downloads no spectra.

JCAMP-DX and CSV files need no extra install, and peak detection uses scipy, a core dependency. Only mzML needs the spectra extra (pyteomics, psims, and lxml):

bash
pip install 'potato-annotation[spectra]'

Configuration

The config below sets up 1H NMR peak picking. The display suggests peaks found by automatic detection, and annotators accept or reject each suggestion, give each peak an assignment and a multiplicity, and integrate each multiplet:

yaml
annotation_task_name: "1H NMR peak picking"
task_dir: .
media_directory: media
output_annotation_dir: annotation_output
data_files: [data/spectra.json]
item_properties: {id_key: id, text_key: note}
 
instance_display:
  fields:
    - key: spectrum               # item field naming the spectrum file
      type: spectrum
      label: "1H NMR"
      display_options:
        prepick:                  # suggested peaks, off unless set
          method: detect
          prominence: 0.05
          max_peaks: 30
 
user_config:
  allow_all_users: true
 
annotation_schemes:
  - annotation_type: peak_picking
    name: peaks
    description: "Pick the peaks, assign each one, and integrate each multiplet"
    viewer_field: spectrum
    peak_fields:
      - name: assignment
        placeholder: "e.g. OCH2"
      - name: multiplicity
        options: [s, d, t, q, m, br]
    region_fields:
      - name: protons
        label: "nH"
    min_peaks: 1
    require_review: true          # every suggestion accepted or rejected
    label_requirement: {required: true}

A field with options is a choice field, and a field without options is a text box. The display turns Integrate mode on by itself for an NMR line spectrum. With min_peaks, require_review, and label_requirement together, Next stays blocked until the annotator has picked at least one peak and has accepted or rejected every suggestion. The viewer_field of the scheme must equal the key of a spectrum field, and the server refuses to start until the two match.

Each item names a spectrum file relative to media_directory:

json
{"id": "ethyl_acetate", "note": "1H NMR, 400 MHz, CDCl3.", "spectrum": "spectra/ethyl_acetate_1h.jdx"}

Reviewing an automatic peak picker

A review of a peak picker's output puts the picker's peaks in each item, and annotators can only accept or reject each suggested peak:

yaml
annotation_task_name: "Peak picker review"
task_dir: .
media_directory: media
output_annotation_dir: annotation_output
data_files: [data/review.json]
item_properties: {id_key: id, text_key: note}
 
instance_display:
  fields:
    - key: spectrum
      type: spectrum
      display_options:
        prepick: {method: item}   # the peaks listed in each item
 
user_config:
  allow_all_users: true
 
annotation_schemes:
  - annotation_type: peak_picking
    name: review
    description: "Accept the real peaks and reject the rest"
    viewer_field: spectrum
    peak_fields: []
    region_fields: []
    require_review: true
    allow_manual: false
    label_requirement: {required: true}
json
{"id": "ethanol", "note": "Peaks from the automatic picker.",
 "spectrum": {"file": "spectra/ethanol_1h.jdx", "peaks": [3.69, 2.61, 1.22, {"x": 7.26, "label": "CHCl3"}]}}

A suggested peak is a number or an {x, y, label} mapping in the item's peaks list. A missing y is read from the spectrum at x, and a label is saved as the peak's label field when the annotator accepts the suggestion. With allow_manual: false, a click away from a suggestion picks nothing, and the "Add a peak at" box is removed. With peak_fields: [] and region_fields: [], the scheme asks for no text beside each peak.

Mass spectra and IR spectra

A centroided mzML scan is drawn as sticks, and a click picks the nearest stick. A JCAMP-DX file with only a peak table is drawn as sticks too, and most JCAMP-DX mass spectra are such files. The partial block below shows the display options for a mass spectrum, with the file's own peak table offered as the 20 tallest suggestions:

yaml
instance_display:
  fields:
    - key: spectrum
      type: spectrum
      display_options:
        prepick: {method: file, max_peaks: 20}

An mzML item names its scan, and a JCAMP-DX compound file names its block:

json
{"id": "r7", "spectrum": {"file": "runs/sample1.mzML", "scan": 1043}}
{"id": "c2", "spectrum": {"file": "spectra/compound.jdx", "block": 1}}

For an IR spectrum in transmittance or reflectance, the display picks minima. A noisy IR spectrum needs a higher prominence, such as 0.1.

Spectrum data

An item field holds a file name in the media directory, or a mapping with the keys in the table below:

KeyMeaning
file or pathThe spectrum file, relative to media_directory
scan, indexWhich spectrum of an mzML run to show. index is the 0-based position in the run, and scan is the scan=N part of the native ID. Without either, the first spectrum is shown
blockWhich block of a JCAMP-DX compound file to show, 0-based, counting only blocks that hold data
peaksSuggested peaks for prepick method item, as numbers or {x, y, label} mappings
x, yAn inline spectrum as two lists of the same length
x_units, y_units, kind, title, sticksUnits, spectrum kind, title, and stick drawing for an inline spectrum

Any other key is refused with a message naming the accepted keys. An inline NMR spectrum looks like the line below:

json
{"id": "s1", "spectrum": {"x": [0.5, 1.0, 1.5], "y": [3, 40, 2], "x_units": "PPM", "kind": "nmr"}}

Potato reads JCAMP-DX itself, including ##XYDATA in every ASDF compression form (with the DIF Y-check verified), ##XYPOINTS, ##PEAK TABLE, the ##NTUPLES form that Bruker and MestReNova write, and compound files with ##BLOCKS. A peak table next to XYDATA is the file's own peak list, and prepick method file offers the list as suggestions. FIDs without an abscissa, 2D NMR, and JCAMP-CS structures are not read.

A CSV file has two columns, x then y, separated by commas, tabs, semicolons, or spaces. Lines starting with # are comments, and an optional header row sets the units from the text in parentheses, such as shift (ppm), wavenumber (cm-1), or transmittance (%T). An mzML file that declares a DOCTYPE or entities is refused before parsing, because mzML uses neither.

Options

spectrum display optionDefaultMeaning
spectrum_typeautonmr, ir, raman, uv, ms, or other. auto reads the JCAMP-DX DATA TYPE or infers the kind from the x units
x_reversedautotrue draws x from high to low. auto reverses NMR (ppm or Hz) and IR (cm⁻¹) axes
x_label, y_labelfrom the unitsAxis titles, such as "Chemical shift (ppm)" or "Wavenumber (cm⁻¹)"
modeautoline or sticks. auto draws a peak table or a centroided mzML scan as sticks
peak_directionautomax or min. auto uses min for transmittance and reflectance
integrateautoOffers Integrate mode. auto turns Integrate mode on for NMR line spectra only
prepickoffSuggested peaks. true, or a mapping with the keys in the next table
snaptrueA click picks the local maximum (or minimum) within 9 pixels of the pointer, instead of the exact pointer position
max_points4000Points sent to the browser, from 200 to 100,000
max_file_mb50Larger spectrum files are refused. At most 500
scan, indexnoneThe mzML spectrum to show for every item. An item's own scan or index takes precedence. Give one of the two
blocknoneThe JCAMP-DX block to show for every item
height380Plot height, in pixels or a CSS length
engineplotlyThe only engine
allow_remotefalsetrue is refused, because spectra are read on the server
prepick keyDefaultMeaning
methodautodetect runs peak detection, file uses the JCAMP-DX peak table, and item uses the item's peaks list. auto uses item, then file, then detect, whichever is available first
prominence0.05For detect, how far a peak must stand above its surroundings, as a fraction of the y range, above 0 and below 1. For sticks, the minimum height as a fraction of the tallest stick
max_peaks40At most this many suggestions, from 1 to 500, keeping the most prominent
min_distancenoneFor detect, the minimum spacing between suggestions, in x units

Detection runs scipy.signal.find_peaks with the prominence threshold on the full-resolution spectrum. Suggestions are off by default, because suggested peaks can anchor annotators. A study that measures how often annotators accept a picker's peaks needs suggestions on.

peak_picking optionDefaultMeaning
viewer_fieldrequiredThe key of the spectrum field in instance_display
peak_fieldsone text field, labelInputs beside each peak, as a list of {name, type, options, required, placeholder, label}. [] gives none
region_fieldsone text field, labelThe same, for each integrated region
min_peaksnoneThe item stays incomplete until this many peaks are picked, when the scheme is required
max_peaksnonePicking more peaks is refused with a message
require_reviewfalseThe item stays incomplete until every suggestion is accepted or rejected, when the scheme is required
allow_manualtruefalse lets peaks come only from the suggestions and removes "Add a peak at"
instructionsnoneText under the question
label_requirementnone{required: true} makes the scheme required
Field keyDefaultMeaning
namerequiredA short identifier of letters, digits, _, or -. A bare string in the list is read as the name
typetext, or choice when options is giventext or choice
optionsnoneThe choices of a choice field. Quote yes, no, on, and off, or YAML reads the four words as booleans
requiredfalseThe field must be filled in for every peak (or region) while the scheme is required
placeholdernoneHint text in an empty text box
labelthe name, capitalizedColumn heading in the table under the plot

Picking and integrating peaks

The toolbar above the plot has three modes:

ModeClickDrag
Pick (default)Picks the peak under the pointer. A click on a picked peak removes the peak, and a click on a suggestion accepts the suggestionZooms to the dragged box
PanNothingMoves along the spectrum
Integrate (when integrate is on)Removes the region under the pointerIntegrates the dragged x range

A double-click on the plot or the Reset view button zooms back out. On a touch screen, a tap works as a click.

With snap on, a click near a line picks the line's top, and annotators zoom in to separate the lines of a multiplet before picking each line. Suggested peaks are drawn as open orange triangles, and picked peaks as filled triangles labeled with the peak's field values. Each region is shaded and labeled with the region's integral relative to the first region, and the first region reads 1.00.

The server computes each integral as a trapezoid integral over every original point between the region's ends, with no baseline correction. A region drawn wider than its multiplet therefore reads high, and the instructions should say which signal to integrate first when the relative integrals need a fixed reference. For a stick spectrum with integrate: true, the integral is the summed intensity of the sticks in the region.

Under the plot, the scheme lists the suggested peaks with Accept and Reject buttons, plus Accept all and Reject all. A rejected suggestion appears as a small button, and pressing the button restores the suggestion. The Peaks table has one row per peak, with the peak's position, intensity, source, and fields, and a Remove button. The Integrals table has one row per region, with the region's range, integral, relative integral, and fields. With the "Add a peak at" box, an annotator types a position and presses Enter, and a position that matches a suggestion accepts the suggestion. Every control is a button or a form field, and an annotator can review a spectrum from the keyboard alone.

Stored answer

A peak_picking scheme stores one JSON object per item:

json
{"viewer": "spectrum",
 "items": [{"kind": "peak", "x": 2.0399, "y": 3003, "source": "auto", "width": 0.0031,
            "fields": {"assignment": "CH3CO", "multiplicity": "s"}},
           {"kind": "peak", "x": 7.26, "y": 132, "source": "manual"},
           {"kind": "region", "from": 1.969, "to": 2.120, "integral": 15.77,
            "fields": {"protons": "3"}}],
 "rejected": [4.1464]}

source is auto for an accepted suggestion and manual for a peak the annotator added. width is the full width at half prominence of a detected peak, in x units. rejected lists the positions of the suggestions the annotator rejected. A suggestion the annotator neither accepted nor rejected is not stored, and require_review in a required scheme makes sure every suggestion ends up in one of the two lists.

Export

The peak_table export writes one row per peak, region, and rejected suggestion:

bash
python -m potato.export --config config.yaml --format peak_table --output out/

The export writes peaks.csv and peaks.jsonl, and --option format=csv or --option format=jsonl writes only one of the two. The JSONL rows leave out empty columns.

ColumnContents
user_id, instance_id, schemaThe annotator, the item, and the scheme
kindpeak, region, or rejected
x, y, widthPeak position, intensity, and width (detected peaks only). A rejected row has x only
from, to, integral, relativeRegion range, integral, and the integral relative to the first region in the same answer
sourceauto or manual for a peak. Regions have manual, and rejected suggestions have auto
labelThe row's field values joined with " / "
one column per fieldSuch as assignment and multiplicity, prefixed with field_ when a name equals a column above

Several annotators can pick peaks on the same spectrum, and num_annotators_per_item sets how many. The generic CSV and JSONL exports also hold the answer, as one JSON cell per scheme. Potato's agreement metrics do not cover peak_picking schemes. Compare annotators' peak lists from peaks.csv after export, for example by matching peaks of one item within a tolerance in ppm or cm⁻¹. For a peak picker review, the counts of source: auto peak rows and rejected rows give each annotator's acceptance rate.

Example

bash
python potato/flask_server.py start examples/advanced/spectrum-peak-picking/config.yaml -p 8000

The example has two synthetic 1H NMR spectra, of ethyl acetate and ethanol, as DIFDUP-compressed JCAMP-DX in Hz, and one synthetic IR transmittance spectrum as CSV, with detected suggestions and require_review on. generate_spectra.py in the same directory regenerates the three files, and the data is invented.

Setting up a study with a coding agent

potato-skill 1.2.0 targets Potato 2.13.1 and knows peak_picking and spectrum by name, and the skill guides the study-design side, such as what to ask annotators, how many annotators pick peaks on each spectrum, training and attention checks, and export. The skill does not yet document the spectrum file formats, the display options, or the spectra extra. Install potato-skill, describe the study to your agent, and point the agent at the Markdown copy of this page, https://www.potatoannotator.com/docs/annotation-types/spectra.md, for the formats and options.

Further Reading

For implementation details, see the source documentation.