SamsungSAILMontreal/chart2table-qwen3.5-4b-v1
0
chart2table — Qwen3.5-4B Fine-tuned
A Qwen3.5-4B model fine-tuned for extracting structured numerical data from line and scatter charts into CSV tables.
Model Description
This model is fine-tuned from Qwen/Qwen3.5-4B using LoRA on our dataset of plots.
Given a chart image, the model outputs a CSV table containing:
- Chart title, x-axis label, and y-axis label as a comment header
- One column per data series, with x-values in the first column
Supported chart types: line charts, scatter charts, dot-line charts.
Intended Use
- Digitising scientific figures from papers and reports
- Automated data extraction from charts for downstream analysis
- Benchmarking and evaluation on chart-understanding tasks
Training Details
Usage
import torch
from PIL import Image
from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
from qwen_vl_utils import process_vision_info
model_id = "SamsungSAILMontreal/chart2table-qwen3.5-4b-v1"
processor = AutoProcessor.from_pretrained(
model_id,
min_pixels=256 * 28 * 28,
max_pixels=1280 * 28 * 28,
)
model = Qwen3_5ForConditionalGeneration.from_pretrained(
model_id,
torch_dtype=torch.bfloat16,
device_map="auto",
)
model.eval()
# Load your chart image
image = Image.open("chart.png").convert("RGB")
prompt = """You are analysing a line or scatter chart.
Your task is to extract the chart title, x-axis label, y-axis label, and raw numerical data from the chart and output it as a CSV table wrapped in a markdown code block.
Instructions:
1. Identify all data series (lines / point sets) in the chart.
2. Extract chart metadata: title, x-axis label, and y-axis label.
3. Read the x-axis values carefully — they may be years, categories, or numeric labels.
4. For each series, extract approximately 5-50 data points, depending on the chart,
that faithfully represent the curve, including start and end points.
5. Column names:
- If a legend is visible, or each curve is directly labelled with text beside it, use those labels as the column names.
- If there is no legend or in-plot labels and there is a single series, use the y-axis label (or "unlabeled" if absent).
- If there is no legend or in-plot labels and there are multiple series, the individual names are not recoverable, so
order the columns from the topmost curve to the bottommost (by average vertical position) and name them
"unlabeled 1", "unlabeled 2", … (or, if a y-axis label is present, "<y-axis label> 1", "<y-axis label> 2", …).
6. Pay close attention to the scale and gridlines when reading y-axis values;
if numerical values on the y-axis are missing, assume the values are scaled between 0 and 1.
7. Use a single shared x column listing every x value across all series (sorted). If a series has no
data point at a given x, leave its cell empty (so a row may look like "x1,,y2").
8. If a series is drawn as data points (markers) with a smooth fitted or guide line through them,
report the DATA POINTS (markers), not the fitted line. Marker fill (solid vs hollow) is only a style;
ignore it. Ignore non-data overlays such as reference lines, trend/annotation arrows, region labels,
and shaded guides.
Output format — markdown CSV code block only, no other text:Title: <chart title>, X: <x-axis label>, Y: <y-axis label>
x-axis label,Series A name,Series B name,... x1,y1A,y1B,... x2,y2A,y2B,...
Example:Title: Company growth report, X: Year, Y: US$
Year,Revenue,Profit 2018,100.5,12.3 2019,115.2,18.7 2020,130.0,22.1
Example with series sampled at different x (note the empty cell):Title: Raman spectra, X: Wavenumber (cm⁻¹), Y: Intensity (a.u.)
Wavenumber (cm⁻¹),Sample 1,Sample 2 100,0.20, 150,0.80,0.30 200,,0.90
"""
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": prompt},
],
}
]
text = processor.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=False,
)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(
text=[text],
images=image_inputs,
videos=video_inputs,
padding=True,
return_tensors="pt",
).to(model.device)
with torch.inference_mode():
generated_ids = model.generate(
**inputs,
max_new_tokens=2048,
do_sample=False,
repetition_penalty=1.2,
)
output = processor.batch_decode(
[ids[len(inp):] for ids, inp in zip(generated_ids, inputs.input_ids)],
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)[0]
print(output)Parsing the output
The model returns a fenced CSV block. A simple way to extract it:
import re, io
import pandas as pd
match = re.search(r"```(?:csv)?\s*(.*?)```", output, re.DOTALL)
if match:
csv_text = match.group(1).strip()
# Strip the optional comment header line
lines = [l for l in csv_text.splitlines() if not l.startswith("#")]
df = pd.read_csv(io.StringIO("\n".join(lines)))
print(df)Extracting meta data (title, axis labels) and visualizing the plot can be done as following:
def extract_metadata_from_text(text: str):
"""Extract title/x/y metadata from comment headers in model/chart text."""
title = ""
x_label = ""
y_label = ""
for raw_line in str(text).splitlines():
line = raw_line.strip()
if not line.startswith("#"):
continue
body = line[1:].strip()
if not body:
continue
fields = list(re.finditer(r"(?:^|,\s*)(title|x|y)\s*:\s*", body, flags=re.IGNORECASE))
if not fields:
continue
for i, match in enumerate(fields):
key = match.group(1).lower()
start = match.end()
end = fields[i + 1].start() if i + 1 < len(fields) else len(body)
value = body[start:end].strip().strip(",").strip()
if not value:
continue
if key == "title" and not title:
title = value
elif key == "x" and not x_label:
x_label = value
elif key == "y" and not y_label:
y_label = value
if title and x_label and y_label:
break
return {"title": title, "xlabel": x_label, "ylabel": y_label}
meta_data = extract_metadata_from_text(output)
df.plot(x=df.columns[0], kind="line", marker='o', figsize=(4, 3), **meta_data)