# Spectrum Peak Picking and Integration

Source: https://www.potatoannotator.com/docs/annotation-types/spectra

*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](https://en.wikipedia.org/wiki/Nuclear_magnetic_resonance_spectroscopy), band assignment in [infrared spectroscopy](https://en.wikipedia.org/wiki/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](https://en.wikipedia.org/wiki/JCAMP-DX) is a text format for spectroscopic data, maintained by IUPAC. [mzML](https://www.psidev.info/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](https://plotly.com/javascript/) 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:

| Key | Meaning |
|---|---|
| `file` or `path` | The spectrum file, relative to `media_directory` |
| `scan`, `index` | Which 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 |
| `block` | Which block of a JCAMP-DX compound file to show, 0-based, counting only blocks that hold data |
| `peaks` | Suggested peaks for `prepick` method `item`, as numbers or `{x, y, label}` mappings |
| `x`, `y` | An inline spectrum as two lists of the same length |
| `x_units`, `y_units`, `kind`, `title`, `sticks` | Units, 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 option | Default | Meaning |
|---|---|---|
| `spectrum_type` | `auto` | `nmr`, `ir`, `raman`, `uv`, `ms`, or `other`. `auto` reads the JCAMP-DX `DATA TYPE` or infers the kind from the x units |
| `x_reversed` | `auto` | `true` draws x from high to low. `auto` reverses NMR (ppm or Hz) and IR (cm⁻¹) axes |
| `x_label`, `y_label` | from the units | Axis titles, such as "Chemical shift (ppm)" or "Wavenumber (cm⁻¹)" |
| `mode` | `auto` | `line` or `sticks`. `auto` draws a peak table or a centroided mzML scan as sticks |
| `peak_direction` | `auto` | `max` or `min`. `auto` uses `min` for transmittance and reflectance |
| `integrate` | `auto` | Offers Integrate mode. `auto` turns Integrate mode on for NMR line spectra only |
| `prepick` | off | Suggested peaks. `true`, or a mapping with the keys in the next table |
| `snap` | `true` | A click picks the local maximum (or minimum) within 9 pixels of the pointer, instead of the exact pointer position |
| `max_points` | `4000` | Points sent to the browser, from 200 to 100,000 |
| `max_file_mb` | `50` | Larger spectrum files are refused. At most 500 |
| `scan`, `index` | none | The mzML spectrum to show for every item. An item's own `scan` or `index` takes precedence. Give one of the two |
| `block` | none | The JCAMP-DX block to show for every item |
| `height` | `380` | Plot height, in pixels or a CSS length |
| `engine` | `plotly` | The only engine |
| `allow_remote` | `false` | `true` is refused, because spectra are read on the server |

| `prepick` key | Default | Meaning |
|---|---|---|
| `method` | `auto` | `detect` 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 |
| `prominence` | `0.05` | For `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_peaks` | `40` | At most this many suggestions, from 1 to 500, keeping the most prominent |
| `min_distance` | none | For `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` option | Default | Meaning |
|---|---|---|
| `viewer_field` | required | The `key` of the `spectrum` field in `instance_display` |
| `peak_fields` | one text field, `label` | Inputs beside each peak, as a list of `{name, type, options, required, placeholder, label}`. `[]` gives none |
| `region_fields` | one text field, `label` | The same, for each integrated region |
| `min_peaks` | none | The item stays incomplete until this many peaks are picked, when the scheme is required |
| `max_peaks` | none | Picking more peaks is refused with a message |
| `require_review` | `false` | The item stays incomplete until every suggestion is accepted or rejected, when the scheme is required |
| `allow_manual` | `true` | `false` lets peaks come only from the suggestions and removes "Add a peak at" |
| `instructions` | none | Text under the question |
| `label_requirement` | none | `{required: true}` makes the scheme required |

| Field key | Default | Meaning |
|---|---|---|
| `name` | required | A short identifier of letters, digits, `_`, or `-`. A bare string in the list is read as the name |
| `type` | `text`, or `choice` when `options` is given | `text` or `choice` |
| `options` | none | The choices of a `choice` field. Quote `yes`, `no`, `on`, and `off`, or YAML reads the four words as booleans |
| `required` | `false` | The field must be filled in for every peak (or region) while the scheme is required |
| `placeholder` | none | Hint text in an empty text box |
| `label` | the name, capitalized | Column heading in the table under the plot |

## Picking and integrating peaks

The toolbar above the plot has three modes:

| Mode | Click | Drag |
|---|---|---|
| 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 suggestion | Zooms to the dragged box |
| Pan | Nothing | Moves along the spectrum |
| Integrate (when `integrate` is on) | Removes the region under the pointer | Integrates 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.

| Column | Contents |
|---|---|
| `user_id`, `instance_id`, `schema` | The annotator, the item, and the scheme |
| `kind` | `peak`, `region`, or `rejected` |
| `x`, `y`, `width` | Peak position, intensity, and width (detected peaks only). A `rejected` row has `x` only |
| `from`, `to`, `integral`, `relative` | Region range, integral, and the integral relative to the first region in the same answer |
| `source` | `auto` or `manual` for a peak. Regions have `manual`, and rejected suggestions have `auto` |
| `label` | The row's field values joined with " / " |
| one column per field | Such 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](/docs/getting-started/quick-start-with-a-coding-agent) 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

- [Chemical Structures](/docs/annotation-types/chemical-structures) - Structure editing and atom selection on molecules
- [Instance Display](/docs/core-concepts/instance-display) - How display fields are configured
- [Export Formats](/docs/features/export-formats) - Potato's export formats and the export command
- [How Many Annotators Do You Need?](/docs/guides/how-many-annotators) - Choosing annotators per item
- [Life Sciences](/use-cases/life-sciences) - Potato for life sciences and biocuration

For implementation details, see the [source documentation](https://github.com/davidjurgens/potato/blob/master/docs/annotation-types/science/spectra.md).
