
Data Extractor
- 18 installs
- 869 repo stars
- Updated June 8, 2026
- beita6969/scienceclaw
data-extractor is a Claude skill that extracts numerical data from scientific figure images using Claude vision and OpenCV calibration.
About
This skill digitizes scientific figures, extracting numerical data from plot images using Claude vision and OpenCV calibration. A developer uses it to read values from bar charts, scatter plots, forest plots, and Kaplan-Meier curves for meta-analyses and systematic reviews. It runs a four-phase pipeline and outputs CSV or JSON, with an optional interactive web UI.
- Extracts numerical data from scientific figure images using Claude vision plus OpenCV
- Supports 26 plot types including bar, scatter, forest, and Kaplan-Meier
- Outputs CSV/JSON with a 4-phase pipeline and optional web UI
Data Extractor by the numbers
- 18 all-time installs (skills.sh)
- Ranked #1,276 of 2,065 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
data-extractor capabilities & compatibility
Requires an ANTHROPIC_API_KEY; uses Claude Sonnet for detection and Claude Opus for extraction
- Capabilities
- data analysis · data stats analysis
- Works with
- anthropic
- Use cases
- data analysis · research · pdf parsing
- Platforms
- macOS · Linux
- Pricing
- Bring your own API key
What data-extractor says it does
Extract numerical data from scientific figure images using Claude vision + OpenCV calibration.
Uses Claude Sonnet for pre-analysis/detection, Claude Opus for extraction
npx skills add https://github.com/beita6969/scienceclaw --skill data-extractorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 18 |
|---|---|
| repo stars | ★ 869 |
| Last updated | June 8, 2026 |
| Repository | beita6969/scienceclaw ↗ |
What it does
Digitize a scientific figure image into CSV/JSON numerical data for a meta-analysis.
Who is it for?
Digitizing plots from papers into CSV/JSON for meta-analyses and systematic reviews
Skip if: Analyzing already-tabular data or full-text extraction
When should I use this skill?
You have a figure image and need to read chart values or convert a plot to CSV/JSON
What you get
Extracted structured numerical data from a scientific figure into CSV/JSON
- extracted CSV data
- structured JSON ExtractedData
- interactive web UI table
By the numbers
- 26 supported plot types
- 4-phase extraction pipeline
- 3 output formats (CSV, JSON, Web UI)
Files
📊 Data Extractor
You are the Data Extractor, a ClawBio skill for digitizing scientific figures. Your role is to extract numerical data from plot images for meta-analyses and systematic reviews.
When to Use This Skill
Route to this skill when the user:
- Provides an image file (PNG, JPG, TIFF) containing a scientific figure
- Asks to "extract data from a figure", "digitize a plot", "read values from a chart"
- Mentions "meta-analysis data extraction" or "figure digitization"
- Wants to convert a bar chart, scatter plot, or other figure to CSV/JSON
Capabilities
Supported Plot Types (26)
scatter, bar, line, box, violin, histogram, heatmap, forest, kaplan_meier, dot_strip, stacked_bar, funnel, roc, volcano, waterfall, bland_altman, paired, bubble, area, dose_response, manhattan, correlation_matrix, error_bar, table, other
Pipeline (4 phases)
1. Panel Detection — Identify sub-panels in multi-panel figures (Claude vision) 2. Pre-Analysis — Identify axes, scale (linear/log), legend entries, error bars (Claude tool calling) 3. CV Calibration + Extraction — OpenCV detects markers/bars at pixel level, Claude extracts numerical data with calibration context 4. Validation — Heuristic checks for axis range, series count, error bar polarity
Output Formats
- CSV — One row per data point with series name, x/y values, error bars
- JSON — Structured ExtractedData objects with full metadata
- Web UI — Interactive table + SVG preview with editable cells
Usage
CLI
python data_extractor.py --image figure.png --output results/
python data_extractor.py --web --port 8765
python data_extractor.py --demoAPI (importable)
from api import run
result = run(options={"image_path": "figure.png", "output_dir": "results/"})Web UI
Launch with --web flag. Upload images, draw boxes around plots, extract and edit data interactively.
Input Formats
- PNG, JPG, JPEG, TIFF image files
- Screenshots from papers, posters, slides
- Multi-panel composite figures (auto-detected and split)
Notes
- Requires ANTHROPIC_API_KEY environment variable
- Uses Claude Sonnet for pre-analysis/detection, Claude Opus for extraction
- OpenCV calibration improves accuracy for scatter/bar plots with clear markers
- Error bars are reported as ± extent (delta from mean), not absolute positions
#!/usr/bin/env python3
"""ClawBio skill API — data-extractor.
Importable run() interface following ClawBio conventions.
"""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
# Ensure skill root is on sys.path for core imports
_SKILL_DIR = Path(__file__).resolve().parent
if str(_SKILL_DIR) not in sys.path:
sys.path.insert(0, str(_SKILL_DIR))
def run(genotypes=None, options=None) -> dict:
"""Run the data extractor.
Parameters
----------
genotypes : ignored (this skill does not consume genotype data)
options : dict with keys:
- image_path (str): Path to figure image
- image_bytes (bytes): Raw image bytes (alternative to path)
- output_dir (str): Where to write CSV/JSON results (default: ./output)
- web (bool): If True, launch web UI (default False)
- port (int): Web UI port (default 8765)
- plot_type (str): Force plot type (optional, auto-detected)
Returns
-------
dict with keys:
- success (bool)
- results (list[dict]): extracted data per panel
- summary (dict): {n_panels, n_series, total_points, confidence}
- output_files (list[str]): paths to generated files
- output_dir (str)
"""
options = options or {}
# Web UI mode
if options.get("web"):
from web.server import launch
port = options.get("port", 8765)
launch(port=port)
return {"success": True, "mode": "web", "port": port}
# Extraction mode
image_path = options.get("image_path")
image_bytes = options.get("image_bytes")
output_dir = options.get("output_dir", str(_SKILL_DIR / "output"))
plot_type = options.get("plot_type")
if not image_path and not image_bytes:
return {
"success": False,
"error": "No image provided. Set image_path or image_bytes in options.",
"results": [],
"summary": {},
"output_files": [],
"output_dir": output_dir,
}
try:
from core.digitizer import extract_from_image, export_csv, export_json
results, _panel_figs = asyncio.run(
extract_from_image(
image_path=image_path,
image_bytes=image_bytes,
plot_type=plot_type,
)
)
except Exception as e:
return {
"success": False,
"error": str(e),
"results": [],
"summary": {},
"output_files": [],
"output_dir": output_dir,
}
# Export results
output_files = []
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
csv_path = export_csv(results, str(out / "extracted_data.csv"))
output_files.append(csv_path)
json_path = export_json(results, str(out / "extracted_data.json"))
output_files.append(json_path)
# Summary
total_series = sum(len(r.series) for r in results)
total_points = sum(len(s.y_values) for r in results for s in r.series)
confidences = [r.confidence.value for r in results]
return {
"success": True,
"results": [r.model_dump() for r in results],
"summary": {
"n_panels": len(results),
"n_series": total_series,
"total_points": total_points,
"confidences": confidences,
},
"output_files": output_files,
"output_dir": output_dir,
}
#!/usr/bin/env python3
"""ClawBio data-extractor CLI.
Usage:
python data_extractor.py --image figure.png --output results/
python data_extractor.py --web --port 8765
python data_extractor.py --demo
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Ensure skill root is on sys.path
_SKILL_DIR = Path(__file__).resolve().parent
if str(_SKILL_DIR) not in sys.path:
sys.path.insert(0, str(_SKILL_DIR))
def main():
parser = argparse.ArgumentParser(
description="ClawBio Data Extractor — digitize scientific figures",
)
parser.add_argument("--image", dest="image_path", help="Path to figure image (PNG/JPG)")
parser.add_argument("--output", "-o", dest="output_dir", help="Output directory for CSV/JSON")
parser.add_argument("--web", action="store_true", help="Launch interactive web UI")
parser.add_argument("--port", type=int, default=8765, help="Web UI port (default: 8765)")
parser.add_argument("--plot-type", dest="plot_type", help="Force plot type (skip auto-detection)")
parser.add_argument("--demo", action="store_true", help="Run on bundled demo figure")
parser.add_argument("--json", action="store_true", help="Output results as JSON to stdout")
args = parser.parse_args()
from api import run
if args.demo:
demo_fig = _SKILL_DIR / "data" / "demo_figure.png"
if not demo_fig.exists():
print("Demo figure not found. Place a figure at data/demo_figure.png")
sys.exit(1)
result = run(options={
"image_path": str(demo_fig),
"output_dir": args.output_dir or str(_SKILL_DIR / "output" / "demo"),
})
elif args.web:
result = run(options={"web": True, "port": args.port})
elif args.image_path:
result = run(options={
"image_path": args.image_path,
"output_dir": args.output_dir or str(_SKILL_DIR / "output"),
"plot_type": args.plot_type,
})
else:
parser.print_help()
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2, default=str))
elif result.get("success"):
summary = result.get("summary", {})
print(f"\n Extracted {summary.get('n_panels', 0)} panel(s), "
f"{summary.get('n_series', 0)} series, "
f"{summary.get('total_points', 0)} data points")
for f in result.get("output_files", []):
print(f" -> {f}")
if result.get("mode") == "web":
print(f" Web UI running on port {result.get('port', 8765)}")
else:
print(f"\n Error: {result.get('error', 'Unknown error')}")
sys.exit(1)
if __name__ == "__main__":
main()
"""Tests for the data-extractor skill."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
# Add skill root to sys.path
_SKILL_DIR = Path(__file__).resolve().parent.parent
if str(_SKILL_DIR) not in sys.path:
sys.path.insert(0, str(_SKILL_DIR))
def test_models_import():
"""Test that core models can be imported."""
from core.models import PlotType, Confidence, Figure, DataSeries, ExtractedData
assert PlotType.BAR.value == "bar"
assert Confidence.HIGH.value == "high"
assert len(PlotType) == 26
def test_plot_type_enum():
"""Test all expected plot types exist."""
from core.models import PlotType
expected = [
"scatter", "bar", "line", "box", "violin", "histogram",
"heatmap", "forest", "kaplan_meier", "dot_strip", "stacked_bar",
"funnel", "roc", "volcano", "waterfall", "bland_altman",
"paired", "bubble", "area", "dose_response", "manhattan",
"correlation_matrix", "error_bar", "table", "other", "non_data",
]
for pt in expected:
assert PlotType(pt), f"PlotType missing: {pt}"
def test_extracted_data_model():
"""Test ExtractedData model creation."""
from core.models import ExtractedData, PlotType, Confidence, DataSeries
data = ExtractedData(
figure_id="test_fig",
plot_type=PlotType.BAR,
title="Test bar chart",
x_label="Category",
y_label="Value",
series=[
DataSeries(
name="Group A",
x_values=["Cat1", "Cat2", "Cat3"],
y_values=[10.5, 20.3, 15.7],
error_bars_lower=[1.2, 2.1, 1.5],
error_bars_upper=[1.3, 1.9, 1.6],
),
],
confidence=Confidence.HIGH,
)
assert data.figure_id == "test_fig"
assert len(data.series) == 1
assert data.series[0].name == "Group A"
assert len(data.series[0].y_values) == 3
def test_cv_calibration_import():
"""Test that cv_calibration module can be imported."""
from core.cv_calibration import (
PlotRegion, DetectedMarker, DetectedBar, CalibrationResult,
calibrate_image, format_calibration_prompt,
)
# Test CalibrationResult creation
result = CalibrationResult()
assert result.markers == []
assert result.bars == []
def test_digitizer_prompts():
"""Test that digitizer prompts are defined."""
from core.digitizer import BASE_PROMPT, PLOT_GUIDANCE, DEFAULT_GUIDANCE
assert "STEP 1" in BASE_PROMPT
assert "STEP 4" in BASE_PROMPT
assert len(PLOT_GUIDANCE) > 10 # Should have many plot types
def test_export_csv(tmp_path):
"""Test CSV export."""
from core.models import ExtractedData, PlotType, Confidence, DataSeries
from core.digitizer import export_csv
results = [
ExtractedData(
figure_id="test",
plot_type=PlotType.SCATTER,
series=[
DataSeries(
name="Series 1",
x_values=[1.0, 2.0, 3.0],
y_values=[4.0, 5.0, 6.0],
),
],
confidence=Confidence.MEDIUM,
),
]
csv_path = export_csv(results, str(tmp_path / "test.csv"))
assert Path(csv_path).exists()
content = Path(csv_path).read_text()
assert "Series 1" in content
assert "scatter" in content
def test_export_json(tmp_path):
"""Test JSON export."""
import json
from core.models import ExtractedData, PlotType, Confidence, DataSeries
from core.digitizer import export_json
results = [
ExtractedData(
figure_id="test",
plot_type=PlotType.BAR,
series=[
DataSeries(name="A", x_values=["x"], y_values=[1.0]),
],
confidence=Confidence.HIGH,
),
]
json_path = export_json(results, str(tmp_path / "test.json"))
assert Path(json_path).exists()
data = json.loads(Path(json_path).read_text())
assert len(data) == 1
assert data[0]["plot_type"] == "bar"
def test_api_run_no_input():
"""Test api.run() with no input returns error."""
from api import run
result = run()
assert result["success"] is False
assert "No image" in result["error"]
def test_validate_extraction():
"""Test heuristic validation."""
from core.models import ExtractedData, PlotType, Confidence, DataSeries
from core.digitizer import validate_extraction
# Valid result — should pass
result = ExtractedData(
figure_id="test",
plot_type=PlotType.BAR,
y_min=0, y_max=100,
series=[
DataSeries(name="A", x_values=["x"], y_values=[50.0]),
],
confidence=Confidence.HIGH,
)
validated = validate_extraction(result)
assert validated.confidence == Confidence.HIGH
# Out-of-range value — should flag
result2 = ExtractedData(
figure_id="test",
plot_type=PlotType.BAR,
y_min=0, y_max=100,
series=[
DataSeries(name="A", x_values=["x"], y_values=[250.0]),
],
confidence=Confidence.HIGH,
)
validated2 = validate_extraction(result2)
assert "outside axis range" in (validated2.notes or "")
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Get Me The Data</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="styles.css">
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="bg-white border-b border-gray-200">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Get Me The Data</h1>
<p class="text-sm text-gray-500 mt-1">Drop a screenshot, draw boxes around plots, extract numerical data</p>
</div>
<div class="flex items-center gap-2 text-sm text-gray-400">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" fill="#10b981" opacity="0.15"/>
<path d="M8 8c0-1 .5-2 1.5-2.5M16 8c0-1-.5-2-1.5-2.5M9 16c.5.5 1.5 1 3 1s2.5-.5 3-1" stroke="#10b981" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="9" cy="10" r="1.5" fill="#10b981"/>
<circle cx="15" cy="10" r="1.5" fill="#10b981"/>
<path d="M7 13.5Q12 17 17 13.5" stroke="#10b981" stroke-width="1" stroke-linecap="round" fill="none" opacity="0.5"/>
</svg>
<span>Made with <strong class="text-emerald-600">ClawBio</strong></span>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-4 py-8 space-y-8">
<!-- Drop Zone (shown when no image loaded) -->
<section id="drop-section" class="bg-white rounded-lg shadow p-6">
<div
id="drop-zone"
class="drop-zone rounded-lg p-16 text-center cursor-pointer"
onclick="document.getElementById('image-upload').click()"
>
<p class="text-gray-400 text-2xl mb-2">Drop an image here</p>
<p class="text-gray-300 text-sm mb-4">PNG, JPG, or paste from clipboard (Ctrl/Cmd+V)</p>
<p class="text-gray-300 text-xs">Screenshots of figures from papers, posters, slides...</p>
</div>
<input id="image-upload" type="file" accept="image/*" class="hidden" onchange="handleImageUpload(event)">
</section>
<!-- Image Viewer (shown after image loaded) -->
<section id="viewer-section" class="bg-white rounded-lg shadow p-6 hidden">
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-3">
<h2 class="text-lg font-semibold text-gray-800">Image</h2>
<button onclick="clearImage()" class="text-xs text-gray-400 hover:text-red-500 underline">Clear</button>
</div>
<div class="flex items-center gap-2">
<button id="auto-detect-btn" onclick="autoDetectPlots()" class="px-3 py-1 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 text-sm">Auto-detect plots</button>
</div>
</div>
<p class="text-xs text-gray-400 mb-2">Click and drag to select a plot region. Adjust or delete boxes, then click Extract.</p>
<div id="viewer-status" class="text-sm mb-2"></div>
<div id="image-viewer" class="relative inline-block w-full" style="cursor:crosshair">
<img id="main-image" class="w-full rounded border border-gray-200 bg-gray-50" alt="Uploaded image" draggable="false">
<div id="plot-boxes-layer" class="absolute inset-0" style="pointer-events:none"></div>
<div id="draw-layer" class="absolute inset-0"></div>
</div>
<div id="page-results" class="mt-4 space-y-4"></div>
</section>
<!-- Extracted Data -->
<section id="results-section" class="bg-white rounded-lg shadow p-6 hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold text-gray-800">Extracted Data</h2>
<div class="flex gap-2">
<button onclick="exportAllCsv()" class="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200">CSV</button>
<button onclick="exportAllJson()" class="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200">JSON</button>
<button onclick="copyPandasCode()" class="text-sm bg-gray-100 px-3 py-1 rounded hover:bg-gray-200">Copy pd.DataFrame</button>
</div>
</div>
<div id="results-container" class="space-y-8"></div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
"""Get Me The Data — lightweight FastAPI server for the data-extractor skill."""
from __future__ import annotations
import base64 as b64mod
import hashlib
import io
import logging
import sys
from pathlib import Path
# Add skill root to sys.path so `from core.models import ...` works
_WEB_DIR = Path(__file__).resolve().parent
_SKILL_ROOT = _WEB_DIR.parent
if str(_SKILL_ROOT) not in sys.path:
sys.path.insert(0, str(_SKILL_ROOT))
from fastapi import FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from PIL import Image as PILImage
from pydantic import BaseModel
from core.models import (
Confidence,
ExtractedData,
Figure,
PlotType,
)
from core.digitizer import digitize_figure
logger = logging.getLogger(__name__)
# In-memory stores
images_store: dict[str, bytes] = {} # image_id -> PNG bytes
images_meta: dict[str, dict] = {} # image_id -> {width, height}
figures_store: dict[str, list[Figure]] = {} # image_id -> figures
extracted_store: dict[str, list[ExtractedData]] = {} # image_id -> results
app = FastAPI(title="Get Me The Data", version="0.2.0")
# Serve static files from the web/ directory (CSS, JS)
app.mount("/static", StaticFiles(directory=str(_WEB_DIR)), name="static")
# Also serve CSS/JS at root level (index.html uses relative paths)
@app.get("/styles.css")
async def serve_css():
return FileResponse(str(_WEB_DIR / "styles.css"), media_type="text/css")
@app.get("/app.js")
async def serve_js():
return FileResponse(str(_WEB_DIR / "app.js"), media_type="application/javascript")
@app.get("/")
async def index():
return FileResponse(str(_WEB_DIR / "index.html"))
# --- Image Upload & Serving ---
@app.post("/api/upload-image")
async def upload_image(file: UploadFile):
"""Upload an image (PNG/JPG/etc) and return an image_id."""
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
content = await file.read()
image_id = hashlib.sha256(content).hexdigest()[:12]
# Convert to PNG and store
img = PILImage.open(io.BytesIO(content))
if img.mode == "RGBA":
bg = PILImage.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
png_bytes = buf.getvalue()
images_store[image_id] = png_bytes
images_meta[image_id] = {"width": img.width, "height": img.height}
return {"image_id": image_id, "width": img.width, "height": img.height}
@app.get("/api/image/{image_id}")
async def get_image(image_id: str):
"""Serve an uploaded image as PNG."""
if image_id not in images_store:
raise HTTPException(status_code=404, detail="Image not found")
return StreamingResponse(
io.BytesIO(images_store[image_id]),
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"},
)
# --- Plot Detection ---
@app.get("/api/detect-plots/{image_id}")
async def detect_plots(image_id: str):
"""Use Claude vision to detect plot regions on an uploaded image."""
import anthropic
if image_id not in images_store:
raise HTTPException(status_code=404, detail="Image not found")
b64 = b64mod.b64encode(images_store[image_id]).decode()
client = anthropic.AsyncAnthropic()
response = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": b64},
},
{
"type": "text",
"text": (
"This is an image (likely a screenshot from a scientific paper). "
"Identify ALL distinct quantitative plots/charts "
"(bar charts, scatter plots, line graphs, box plots, heatmaps, "
"forest plots, Kaplan-Meier curves, histograms, violin plots, etc.).\n\n"
"Do NOT include: text-only areas, photographs, Western blots, gel images, "
"microscopy images, schematics, flowcharts, or diagrams without axes.\n"
"DO include: tables with numerical data.\n\n"
"For each plot found, return its bounding box as percentages (0-100) "
"of the image. Include the axis labels and tick marks in the box.\n\n"
"Return JSON:\n"
'{"plots": [\n'
' {"label": "a", "type": "bar", "title": "short description", '
'"x_pct": 5, "y_pct": 10, "w_pct": 45, "h_pct": 40},\n'
" ...\n"
"]}\n\n"
"If no quantitative plots are found, return: {\"plots\": []}\n"
"Plot type must be one of: scatter, bar, line, box, violin, histogram, "
"heatmap, forest, kaplan_meier, dot_strip, stacked_bar, funnel, roc, "
"volcano, waterfall, bland_altman, paired, bubble, area, dose_response, "
"manhattan, correlation_matrix, error_bar, table, other.\n"
"Return ONLY the JSON."
),
},
],
}],
)
import json
raw = response.content[0].text.strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1]
if raw.endswith("```"):
raw = raw[:-3]
raw = raw.strip()
try:
data = json.loads(raw)
return data
except json.JSONDecodeError:
return {"plots": []}
# --- Extraction ---
class ImageRegionRequest(BaseModel):
image_id: str
crop_x_pct: float | None = None
crop_y_pct: float | None = None
crop_w_pct: float | None = None
crop_h_pct: float | None = None
@app.post("/api/extract-image-region")
async def extract_image_region(req: ImageRegionRequest):
"""Extract data from a region of an uploaded image."""
if req.image_id not in images_store:
raise HTTPException(status_code=404, detail="Image not found")
img = PILImage.open(io.BytesIO(images_store[req.image_id]))
# Crop if region specified
if req.crop_x_pct is not None:
x = int(req.crop_x_pct / 100 * img.width)
y = int(req.crop_y_pct / 100 * img.height)
w = int(req.crop_w_pct / 100 * img.width)
h = int(req.crop_h_pct / 100 * img.height)
img = img.crop((x, y, x + w, y + h))
# Resize if too large
if img.width > 1500 or img.height > 1500:
img.thumbnail((1500, 1500), PILImage.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=True)
b64 = b64mod.b64encode(buf.getvalue()).decode()
figure_id = req.image_id + "_region"
fig = Figure(
figure_id=figure_id,
paper_id=req.image_id,
page_number=1,
image_index=0,
width=img.width,
height=img.height,
image_base64=b64,
plot_type=PlotType.OTHER,
plot_type_confidence=Confidence.MEDIUM,
)
# Store so image endpoint can serve panels
if req.image_id not in figures_store:
figures_store[req.image_id] = []
figures_store[req.image_id].append(fig)
try:
results, panel_figures = await digitize_figure(fig)
if req.image_id not in extracted_store:
extracted_store[req.image_id] = []
extracted_store[req.image_id].extend(results)
if panel_figures:
figures_store[req.image_id].extend(panel_figures)
return [r.model_dump() for r in results]
except Exception as e:
raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}")
# --- Figure image serving (for panel crops) ---
@app.get("/api/figure-image/{figure_id}")
async def get_figure_image(figure_id: str):
"""Serve a figure/panel image as PNG."""
for figs in figures_store.values():
for fig in figs:
if fig.figure_id == figure_id:
img_bytes = b64mod.b64decode(fig.image_base64)
return StreamingResponse(
io.BytesIO(img_bytes),
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"},
)
raise HTTPException(status_code=404, detail="Figure not found")
# --- Edit extracted data ---
class EditCellRequest(BaseModel):
image_id: str
result_index: int
series_index: int
field: str # "x_values" or "y_values"
point_index: int
value: float | str
@app.patch("/api/edit-cell")
async def edit_cell(req: EditCellRequest):
"""Edit a single extracted data point (user correction)."""
if req.image_id not in extracted_store:
raise HTTPException(status_code=404, detail="No results for this image")
results = extracted_store[req.image_id]
if req.result_index >= len(results):
raise HTTPException(status_code=404, detail="Result index out of range")
result = results[req.result_index]
if req.series_index >= len(result.series):
raise HTTPException(status_code=404, detail="Series index out of range")
series = result.series[req.series_index]
arr = getattr(series, req.field, None)
if arr is None or req.field not in ("x_values", "y_values", "error_bars_lower", "error_bars_upper"):
raise HTTPException(status_code=400, detail=f"Invalid field: {req.field}")
if req.point_index >= len(arr):
raise HTTPException(status_code=404, detail="Point index out of range")
# Apply the edit
if req.field == "y_values":
arr[req.point_index] = float(req.value)
elif req.field in ("error_bars_lower", "error_bars_upper"):
arr[req.point_index] = float(req.value) if req.value is not None else None
else:
arr[req.point_index] = req.value
return {"ok": True}
def launch(port: int = 8765):
"""Launch the server on the given port."""
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=port,
)
if __name__ == "__main__":
launch()
/* Custom styles beyond Tailwind */
.figure-card {
transition: all 0.2s ease;
}
.figure-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.figure-card.selected {
ring: 2px solid #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
.badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 9999px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-scatter { background: #dbeafe; color: #1e40af; }
.badge-bar { background: #dcfce7; color: #166534; }
.badge-line { background: #fef3c7; color: #92400e; }
.badge-box { background: #f3e8ff; color: #6b21a8; }
.badge-violin { background: #fce7f3; color: #9d174d; }
.badge-histogram { background: #e0e7ff; color: #3730a3; }
.badge-heatmap { background: #ffedd5; color: #9a3412; }
.badge-forest { background: #d1fae5; color: #065f46; }
.badge-kaplan_meier { background: #fecaca; color: #991b1b; }
.badge-table { background: #cffafe; color: #155e75; }
.badge-other { background: #f3f4f6; color: #374151; }
.badge-non_data { background: #e5e7eb; color: #6b7280; }
.confidence-high { color: #059669; }
.confidence-medium { color: #d97706; }
.confidence-low { color: #dc2626; }
.spinner {
border: 3px solid #e5e7eb;
border-top: 3px solid #3b82f6;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 0.8s linear infinite;
display: inline-block;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.data-table input {
width: 100%;
border: 1px solid transparent;
background: transparent;
padding: 2px 4px;
font-family: monospace;
font-size: 0.85rem;
}
.data-table input:hover {
border-color: #d1d5db;
}
.data-table input:focus {
border-color: #3b82f6;
outline: none;
background: #eff6ff;
}
.drop-zone {
border: 2px dashed #d1d5db;
transition: all 0.2s;
background: #fafafa;
}
.drop-zone:hover {
border-color: #93c5fd;
background: #f0f7ff;
}
.drop-zone.dragover {
border-color: #3b82f6;
background: #eff6ff;
transform: scale(1.01);
}
/* Drawing preview rectangle */
.draw-preview {
position: absolute;
border: 2px dashed #7c3aed;
background: rgba(124, 58, 237, 0.08);
pointer-events: none;
box-sizing: border-box;
}
/* Plot detection boxes */
.plot-box {
position: absolute;
border: 2px solid #3b82f6;
background: rgba(59, 130, 246, 0.06);
cursor: move;
box-sizing: border-box;
min-width: 30px;
min-height: 30px;
pointer-events: auto;
transition: border-color 0.15s, background 0.15s;
}
.plot-box:hover {
border-color: #2563eb;
background: rgba(59, 130, 246, 0.12);
}
.plot-box.extracting {
border-color: #8b5cf6;
background: rgba(139, 92, 246, 0.1);
animation: pulse-border 1.5s ease-in-out infinite;
}
@keyframes pulse-border {
0%, 100% { border-color: #8b5cf6; }
50% { border-color: #c4b5fd; }
}
.plot-box.done {
border-color: #10b981;
background: rgba(16, 185, 129, 0.06);
}
.plot-box .box-handle {
position: absolute;
width: 10px;
height: 10px;
background: #3b82f6;
border: 1px solid white;
border-radius: 2px;
z-index: 2;
}
.box-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
.box-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.box-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
.box-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
.plot-box .box-controls {
position: absolute;
top: -28px;
right: -2px;
display: flex;
gap: 2px;
z-index: 3;
white-space: nowrap;
}
.plot-box .box-label {
position: absolute;
top: -28px;
left: -2px;
font-size: 11px;
font-weight: 600;
background: #3b82f6;
color: white;
padding: 2px 8px;
border-radius: 4px 4px 0 0;
z-index: 3;
white-space: nowrap;
}
.plot-box .box-btn {
font-size: 11px;
padding: 2px 8px;
border: none;
border-radius: 3px;
cursor: pointer;
font-weight: 500;
}
.plot-box .btn-extract {
background: #7c3aed;
color: white;
}
.plot-box .btn-extract:hover { background: #6d28d9; }
.plot-box .btn-delete {
background: #fecaca;
color: #991b1b;
}
.plot-box .btn-delete:hover { background: #fca5a5; }
Related skills
FAQ
How many plot types are supported?
26 types including scatter, bar, line, box, violin, histogram, heatmap, forest, and kaplan_meier.
How are error bars reported?
As plus/minus extent (delta from the mean), not absolute positions.