t29mato's picture
Initial deploy: AutoLineDigitizer Gradio API for HuggingFace Spaces
27c91ef
Raw
History Blame Contribute Delete
30.7 kB
# -*- coding: utf-8 -*-
"""
LineFormer Streamlit App
Chart line data extraction using LineFormer.
Automatic axis detection using ChartDete + OCR.
Output compatible with StarryDigitizer and WebPlotDigitizer formats.
"""
import sys
import os
# Project root is parent of src/
_src_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_src_dir)
# Add ChartDete submodule to path FIRST (highest priority for custom mmdet models)
sys.path.insert(0, os.path.join(_project_root, 'submodules', 'chartdete'))
# Add lineformer submodule to path
sys.path.insert(0, os.path.join(_project_root, 'submodules', 'lineformer'))
# Import mmdet (uses ChartDete's mmdet which includes custom models like CascadeRoIHead_LGF)
import mmdet # noqa: F401
from mmdet.models.roi_heads.cascade_roi_head_LGF import CascadeRoIHead_LGF # noqa: F401
# Add src to path for local imports
sys.path.insert(0, _src_dir)
import streamlit as st
import cv2
import numpy as np
import json
import io
import zipfile
import tarfile
from datetime import datetime
# Page config
st.set_page_config(
page_title="AutoLineDigitizer",
page_icon="πŸ“ˆ",
layout="wide"
)
@st.cache_resource
def load_lineformer_model():
"""Load LineFormer model (cached)."""
import infer
# Model weights in models/, config in submodules/lineformer/
CKPT = os.path.join(_project_root, "models", "iter_3000.pth")
CONFIG = os.path.join(_project_root, "submodules", "lineformer", "lineformer_swin_t_config.py")
DEVICE = "cpu"
infer.load_model(CONFIG, CKPT, DEVICE)
return infer
@st.cache_resource
def load_chartdete_model():
"""Load ChartDete model for axis detection (cached)."""
import chartdete_infer
chartdete_infer.load_chartdete_model(device='cpu')
return chartdete_infer
def detect_axis_calibration(chartdete_module, img):
"""
Detect chart elements and extract axis calibration using ChartDete + OCR.
Returns:
axis_config: dict with calibration data or None
detections: raw detection results
ocr_results: OCR results for labels
"""
# Run ChartDete detection
detections = chartdete_module.detect_chart_elements(img, score_thr=0.3)
# Get axis info with OCR
axis_info = chartdete_module.get_axis_info(detections, img=img, with_ocr=True)
calibration = axis_info.get('calibration')
ocr_results = axis_info.get('ocr_results', {})
if calibration is None:
return None, detections, ocr_results
# Convert calibration to axis_config format
# For x-axis: use xlabel center positions (x pixel, fixed y at label position)
# For y-axis: use ylabel center positions (fixed x at label position, y pixel)
axis_config = None
has_x = 'x1_pixel' in calibration and 'x2_pixel' in calibration
has_y = 'y1_pixel' in calibration and 'y2_pixel' in calibration
if has_x and has_y:
# Get plot_area to determine y position for x-axis calibration points
plot_area = axis_info.get('plot_area')
if plot_area:
# x calibration: use bottom of plot area for y
x_calib_y = plot_area[3] # y2 of plot_area (bottom)
# y calibration: use left of plot area for x
y_calib_x = plot_area[0] # x1 of plot_area (left)
else:
# Fallback: use image dimensions
x_calib_y = img.shape[0] * 0.9
y_calib_x = img.shape[1] * 0.1
# Note: In calibration from OCR:
# y1_pixel/y1_value = top label (higher Y pixel, but could be higher or lower value)
# y2_pixel/y2_value = bottom label (lower Y pixel)
# In StarryDigitizer/WPD format:
# y1 = bottom point (lower Y pixel = higher on screen)
# y2 = top point (higher Y pixel = lower on screen)
# So we swap y1 and y2 from OCR calibration
axis_config = {
# X axis calibration points (at bottom of chart)
"x1_px": calibration['x1_pixel'],
"x1_py": x_calib_y,
"x1_val": calibration['x1_value'],
"x2_px": calibration['x2_pixel'],
"x2_py": x_calib_y,
"x2_val": calibration['x2_value'],
# Y axis calibration points (at left of chart)
# y1 = bottom (higher pixel Y), y2 = top (lower pixel Y)
"y1_px": y_calib_x,
"y1_py": calibration['y2_pixel'], # bottom label
"y1_val": calibration['y2_value'],
"y2_px": y_calib_x,
"y2_py": calibration['y1_pixel'], # top label
"y2_val": calibration['y1_value'],
"xIsLogScale": False,
"yIsLogScale": False,
}
return axis_config, detections, ocr_results
def downsample_points(points, mode, fixed_step, max_points):
"""Downsample points based on mode."""
if len(points) <= 1:
return points
if mode == "none":
return points
elif mode == "fixed":
return points[::fixed_step]
elif mode == "max_points":
if len(points) <= max_points:
return points
step = max(1, len(points) // max_points)
return points[::step]
return points
def sort_data_series(data_series, sort_mode):
"""Sort data series based on the specified mode."""
if sort_mode == "original" or len(data_series) == 0:
return data_series
if sort_mode == "mean_y_desc":
# Sort by mean Y descending (higher Y value = lower on screen in image coords)
# For chart interpretation: lower Y pixel = higher value, so desc means high→low value
return sorted(data_series, key=lambda s: np.mean([pt[1] for pt in s["points"]]))
elif sort_mode == "mean_y_asc":
# Sort by mean Y ascending
return sorted(data_series, key=lambda s: np.mean([pt[1] for pt in s["points"]]), reverse=True)
return data_series
def extract_lines(infer_module, img, downsample_mode, fixed_step, max_points):
"""Extract line data from image."""
line_dataseries = infer_module.get_dataseries(img, to_clean=False)
data_series = []
for line in line_dataseries:
if len(line) == 0:
continue
# Extract all points
all_points = [[int(pt['x']), int(pt['y'])] for pt in line]
# Downsample
points = downsample_points(all_points, downsample_mode, fixed_step, max_points)
data_series.append({"points": points})
return data_series, line_dataseries
def draw_points_on_image(img, data_series, axis_config=None):
"""Draw extracted points as symbols on image, with optional axis calibration markers."""
import line_utils
result_img = img.copy()
num_lines = len(data_series)
colors = list(line_utils.get_distinct_colors(num_lines))
# Marker symbols (using different shapes)
markers = [
cv2.MARKER_CROSS,
cv2.MARKER_DIAMOND,
cv2.MARKER_SQUARE,
cv2.MARKER_TRIANGLE_UP,
cv2.MARKER_TRIANGLE_DOWN,
cv2.MARKER_STAR,
]
for line_idx, series in enumerate(data_series):
color = colors[line_idx]
marker = markers[line_idx % len(markers)]
for pt in series["points"]:
x, y = int(pt[0]), int(pt[1])
cv2.drawMarker(result_img, (x, y), color, marker, markerSize=8, thickness=2)
# Draw axis calibration points if available
if axis_config is not None:
# Single color for all calibration points (Magenta - visible on white backgrounds)
calib_color = (255, 0, 255) # Magenta (BGR)
outline_color = (0, 0, 0) # Black outline for contrast
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.8
thickness = 2
# Helper function to draw text with background
def draw_text_with_bg(img, text, pos, color):
(text_w, text_h), baseline = cv2.getTextSize(text, font, font_scale, thickness)
x, y = pos
# Draw background rectangle
padding = 3
cv2.rectangle(img, (x - padding, y - text_h - padding),
(x + text_w + padding, y + baseline + padding),
(255, 255, 255), -1) # White background
cv2.rectangle(img, (x - padding, y - text_h - padding),
(x + text_w + padding, y + baseline + padding),
outline_color, 1) # Black border
# Draw text
cv2.putText(img, text, (x, y), font, font_scale, color, thickness)
# Helper function to draw calibration point with marker
def draw_calib_point(img, x, y, color, label, label_offset):
# Draw filled circle with outline
cv2.circle(img, (x, y), 12, outline_color, 3) # Black outline
cv2.circle(img, (x, y), 10, color, -1) # Filled circle
cv2.circle(img, (x, y), 10, outline_color, 2) # Inner outline
# Draw crosshair inside circle
cv2.line(img, (x - 6, y), (x + 6, y), outline_color, 2)
cv2.line(img, (x, y - 6), (x, y + 6), outline_color, 2)
# Draw label with background
label_x = x + label_offset[0]
label_y = y + label_offset[1]
draw_text_with_bg(img, label, (label_x, label_y), color)
# Get calibration points
x1_x, x1_y = int(axis_config['x1_px']), int(axis_config['x1_py'])
x2_x, x2_y = int(axis_config['x2_px']), int(axis_config['x2_py'])
y1_x, y1_y = int(axis_config['y1_px']), int(axis_config['y1_py'])
y2_x, y2_y = int(axis_config['y2_px']), int(axis_config['y2_py'])
# Draw dashed lines connecting calibration points
# X-axis line (X1 to X2)
dash_length = 10
gap_length = 5
# Draw dashed line for X-axis
dx = x2_x - x1_x
dy = x2_y - x1_y
dist = max(1, int(np.sqrt(dx*dx + dy*dy)))
for i in range(0, dist, dash_length + gap_length):
start_x = int(x1_x + dx * i / dist)
start_y = int(x1_y + dy * i / dist)
end_i = min(i + dash_length, dist)
end_x = int(x1_x + dx * end_i / dist)
end_y = int(x1_y + dy * end_i / dist)
cv2.line(result_img, (start_x, start_y), (end_x, end_y), calib_color, 2)
# Draw dashed line for Y-axis
dx = y2_x - y1_x
dy = y2_y - y1_y
dist = max(1, int(np.sqrt(dx*dx + dy*dy)))
for i in range(0, dist, dash_length + gap_length):
start_x = int(y1_x + dx * i / dist)
start_y = int(y1_y + dy * i / dist)
end_i = min(i + dash_length, dist)
end_x = int(y1_x + dx * end_i / dist)
end_y = int(y1_y + dy * end_i / dist)
cv2.line(result_img, (start_x, start_y), (end_x, end_y), calib_color, 2)
# Draw calibration points with labels
# X1 point (left on X axis)
draw_calib_point(result_img, x1_x, x1_y, calib_color,
f"X1={axis_config['x1_val']}", (15, -5))
# X2 point (right on X axis) - label on left side to avoid edge
draw_calib_point(result_img, x2_x, x2_y, calib_color,
f"X2={axis_config['x2_val']}", (-100, -5))
# Y1 point (bottom on Y axis)
draw_calib_point(result_img, y1_x, y1_y, calib_color,
f"Y1={axis_config['y1_val']}", (15, 20))
# Y2 point (top on Y axis)
draw_calib_point(result_img, y2_x, y2_y, calib_color,
f"Y2={axis_config['y2_val']}", (15, -5))
return result_img
def convert_to_starry_digitizer_format(data_series, img_shape, axis_config=None):
"""
Convert LineFormer output to StarryDigitizer project.json format.
axis_config: dict with x1, x2, y1, y2 pixel coordinates and values
"""
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
# Default axis set (user needs to calibrate in StarryDigitizer)
if axis_config is None:
# Use image corners as default
axis_set = {
"id": 1,
"name": "XY Axes 1",
"x1": {
"name": "x1",
"value": 0,
"coord": {"xPx": 0, "yPx": float(img_shape[0])}
},
"x2": {
"name": "x2",
"value": 100,
"coord": {"xPx": float(img_shape[1]), "yPx": float(img_shape[0])}
},
"y1": {
"name": "y1",
"value": 0,
"coord": {"xPx": 0, "yPx": float(img_shape[0])}
},
"y2": {
"name": "y2",
"value": 100,
"coord": {"xPx": 0, "yPx": 0}
},
"xIsLogScale": False,
"yIsLogScale": False,
"considerGraphTilt": False,
"pointMode": 0,
"isVisible": True
}
else:
axis_set = {
"id": 1,
"name": "XY Axes 1",
"x1": {
"name": "x1",
"value": axis_config["x1_val"],
"coord": {"xPx": axis_config["x1_px"], "yPx": axis_config["x1_py"]}
},
"x2": {
"name": "x2",
"value": axis_config["x2_val"],
"coord": {"xPx": axis_config["x2_px"], "yPx": axis_config["x2_py"]}
},
"y1": {
"name": "y1",
"value": axis_config["y1_val"],
"coord": {"xPx": axis_config["y1_px"], "yPx": axis_config["y1_py"]}
},
"y2": {
"name": "y2",
"value": axis_config["y2_val"],
"coord": {"xPx": axis_config["y2_px"], "yPx": axis_config["y2_py"]}
},
"xIsLogScale": axis_config.get("xIsLogScale", False),
"yIsLogScale": axis_config.get("yIsLogScale", False),
"considerGraphTilt": False,
"pointMode": 0,
"isVisible": True
}
# Convert datasets
datasets = []
# Add empty dataset 1 (StarryDigitizer convention)
datasets.append({
"id": 1,
"name": "dataset 1",
"axisSetId": 1,
"points": [],
"visiblePointIds": [],
"manuallyAddedPointIds": []
})
# Add extracted lines
for idx, series in enumerate(data_series):
points = []
visible_ids = []
for pt_idx, pt in enumerate(series["points"]):
pt_id = pt_idx + 1
points.append({
"id": pt_id,
"xPx": float(pt[0]),
"yPx": float(pt[1])
})
visible_ids.append(pt_id)
datasets.append({
"id": idx + 2, # Start from 2 (1 is empty dataset)
"name": f"Line {idx + 1}",
"axisSetId": 1,
"points": points,
"visiblePointIds": visible_ids,
"manuallyAddedPointIds": []
})
project = {
"version": "1.11.2",
"timestamp": timestamp,
"axisSets": [axis_set],
"activeAxisSetId": 1,
"datasets": datasets,
"activeDatasetId": len(datasets),
"canvasHandler": {
"scale": 1.0,
"manualMode": 0
}
}
return project
def create_starry_digitizer_zip(img, project_json):
"""
Create a ZIP file containing image.png and project.json
for StarryDigitizer import.
"""
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
# Add image.png
_, img_encoded = cv2.imencode('.png', img)
zf.writestr('image.png', img_encoded.tobytes())
# Add project.json
json_str = json.dumps(project_json, indent=2, ensure_ascii=False)
zf.writestr('project.json', json_str.encode('utf-8'))
zip_buffer.seek(0)
return zip_buffer
def convert_to_wpd_format(data_series, img_shape, axis_config=None):
"""
Convert LineFormer output to WebPlotDigitizer JSON format.
This format can be loaded in WebPlotDigitizer after loading the image.
"""
# Default calibration points (user needs to recalibrate in WPD)
if axis_config is None:
calibration_points = [
{"px": 0.0, "py": float(img_shape[0]), "dx": "0", "dy": "0", "dz": None},
{"px": float(img_shape[1]), "py": float(img_shape[0]), "dx": "100", "dy": "0", "dz": None},
{"px": 0.0, "py": float(img_shape[0]), "dx": "0", "dy": "0", "dz": None},
{"px": 0.0, "py": 0.0, "dx": "0", "dy": "100", "dz": None}
]
is_log_x = False
is_log_y = False
else:
calibration_points = [
{"px": axis_config["x1_px"], "py": axis_config["x1_py"],
"dx": str(axis_config["x1_val"]), "dy": str(axis_config["y1_val"]), "dz": None},
{"px": axis_config["x2_px"], "py": axis_config["x2_py"],
"dx": str(axis_config["x2_val"]), "dy": str(axis_config["y1_val"]), "dz": None},
{"px": axis_config["y1_px"], "py": axis_config["y1_py"],
"dx": str(axis_config["x1_val"]), "dy": str(axis_config["y1_val"]), "dz": None},
{"px": axis_config["y2_px"], "py": axis_config["y2_py"],
"dx": str(axis_config["x1_val"]), "dy": str(axis_config["y2_val"]), "dz": None}
]
is_log_x = axis_config.get("xIsLogScale", False)
is_log_y = axis_config.get("yIsLogScale", False)
axes_coll = [{
"name": "XY",
"type": "XYAxes",
"isLogX": is_log_x,
"isLogY": is_log_y,
"noRotation": False,
"calibrationPoints": calibration_points
}]
# Convert datasets
dataset_coll = []
# Add empty default dataset (WPD convention)
dataset_coll.append({
"name": "Default Dataset",
"axesName": "XY",
"colorRGB": [200, 0, 0, 255],
"metadataKeys": [],
"data": [],
"autoDetectionData": None
})
# Add extracted lines
for idx, series in enumerate(data_series):
data_points = []
for pt in series["points"]:
# WPD format: x, y are pixel coords, value is [realX, realY] (null if not calibrated)
data_points.append({
"x": float(pt[0]),
"y": float(pt[1]),
"value": None # Will be calculated by WPD after calibration
})
dataset_coll.append({
"name": f"Dataset {idx + 1}",
"axesName": "XY",
"colorRGB": [200, 0, 0, 255],
"metadataKeys": [],
"data": data_points,
"autoDetectionData": None
})
wpd_json = {
"version": [4, 2],
"axesColl": axes_coll,
"datasetColl": dataset_coll,
"measurementColl": []
}
return wpd_json
def create_wpd_tar(img, wpd_json, project_name="project"):
"""
Create a TAR file for WebPlotDigitizer import.
WPD expects TAR structure:
projectName/
projectName/info.json
projectName/wpd.json
projectName/image.png
"""
import time
tar_buffer = io.BytesIO()
mtime = time.time()
with tarfile.open(fileobj=tar_buffer, mode='w') as tf:
# Add project folder
folder_info = tarfile.TarInfo(name=f'{project_name}/')
folder_info.type = tarfile.DIRTYPE
folder_info.mtime = mtime
tf.addfile(folder_info)
# Add info.json
info_json = {
"version": [4, 0],
"json": "wpd.json",
"images": ["image.png"]
}
info_bytes = json.dumps(info_json, ensure_ascii=False).encode('utf-8')
info_tarinfo = tarfile.TarInfo(name=f'{project_name}/info.json')
info_tarinfo.size = len(info_bytes)
info_tarinfo.mtime = mtime
tf.addfile(info_tarinfo, io.BytesIO(info_bytes))
# Add wpd.json
wpd_bytes = json.dumps(wpd_json, ensure_ascii=False).encode('utf-8')
wpd_tarinfo = tarfile.TarInfo(name=f'{project_name}/wpd.json')
wpd_tarinfo.size = len(wpd_bytes)
wpd_tarinfo.mtime = mtime
tf.addfile(wpd_tarinfo, io.BytesIO(wpd_bytes))
# Add image.png
_, img_encoded = cv2.imencode('.png', img)
img_bytes = img_encoded.tobytes()
img_tarinfo = tarfile.TarInfo(name=f'{project_name}/image.png')
img_tarinfo.size = len(img_bytes)
img_tarinfo.mtime = mtime
tf.addfile(img_tarinfo, io.BytesIO(img_bytes))
tar_buffer.seek(0)
return tar_buffer
def main():
st.title("πŸ“ˆ AutoLineDigitizer")
st.markdown("""
Upload a chart image to extract line data automatically.
Output is compatible with **[StarryDigitizer](https://starrydigitizer.vercel.app/)** and **[WebPlotDigitizer](https://apps.automeris.io/wpd4/)**.
**[LineFormer Paper (ICDAR 2023)](https://arxiv.org/abs/2305.01837)** |
**[ChartDete Paper (ICDAR 2023)](https://arxiv.org/abs/2305.04151)**
""")
# Sidebar settings
st.sidebar.header("Settings")
show_visualization = st.sidebar.checkbox("Show visualization", value=True)
st.sidebar.subheader("Axis Detection")
auto_axis = st.sidebar.checkbox("Auto-detect axis (ChartDete + OCR)", value=True,
help="Automatically detect axis labels and calibration")
st.sidebar.subheader("Line Sorting")
sort_mode = st.sidebar.selectbox(
"Sort by",
options=["original", "mean_y_desc", "mean_y_asc"],
format_func=lambda x: {
"original": "Original (Detection Order)",
"mean_y_desc": "Mean Y (High β†’ Low)",
"mean_y_asc": "Mean Y (Low β†’ High)",
}[x]
)
st.sidebar.subheader("Downsampling")
downsample_mode = st.sidebar.selectbox(
"Mode",
options=["max_points", "fixed", "none"],
index=0,
help="max_points: Limit points per line, fixed: Every N points, none: All points"
)
if downsample_mode == "fixed":
fixed_step = st.sidebar.slider("Fixed step (every N points)", 1, 50, 10)
max_points = 50
elif downsample_mode == "max_points":
max_points = st.sidebar.slider("Max points per line", 10, 200, 50)
fixed_step = 10
else:
fixed_step = 10
max_points = 50
# Load LineFormer model
with st.spinner("Loading LineFormer model..."):
try:
infer_module = load_lineformer_model()
st.sidebar.success("LineFormer loaded!")
except Exception as e:
st.error(f"Failed to load LineFormer: {e}")
st.stop()
# Load ChartDete model if needed
chartdete_module = None
if auto_axis:
with st.spinner("Loading ChartDete model..."):
try:
chartdete_module = load_chartdete_model()
st.sidebar.success("ChartDete loaded!")
except Exception as e:
st.warning(f"ChartDete not available: {e}")
auto_axis = False
# File upload
uploaded_file = st.file_uploader(
"Upload a chart image",
type=["png", "jpg", "jpeg", "bmp", "tiff"]
)
# Status placeholder right after file uploader (below Drag and drop)
status_placeholder = st.empty()
if uploaded_file is not None:
# Read image
file_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8)
img = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
if img is None:
st.error("Failed to read image")
st.stop()
# Initialize axis detection variables
axis_config = None
detections = None
ocr_results = None
# Display columns - show input image immediately
col1, col2 = st.columns(2)
with col1:
st.subheader("Input Image")
st.image(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), use_container_width=True)
st.caption(f"Size: {img.shape[1]} x {img.shape[0]} pixels")
with col2:
# Create placeholders for dynamic updates
st.subheader("Extraction Result")
viz_placeholder = st.empty()
summary_placeholder = st.empty()
caption_placeholder = st.empty()
# Placeholder for axis calibration results (outside columns)
axis_placeholder = st.empty()
# Step 1: Extract lines (faster) - with spinner
with status_placeholder.container():
with st.spinner("⏳ Extracting lines (LineFormer)..."):
data_series, raw_lines = extract_lines(
infer_module, img, downsample_mode, fixed_step, max_points
)
# Sort lines
data_series = sort_data_series(data_series, sort_mode)
# Show initial result (without axis calibration) immediately
if show_visualization:
result_img = draw_points_on_image(img, data_series, None)
viz_placeholder.image(cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB), use_container_width=True)
# Show line summary
total_points = sum(len(s['points']) for s in data_series)
line_pts = [len(s['points']) for s in data_series]
summary_placeholder.success(f"**{len(data_series)} lines** detected ({total_points} points total)")
caption_placeholder.caption(f"Points per line: {', '.join(map(str, line_pts))}")
# Step 2: Run axis detection (slower, OCR-heavy) - with spinner
if auto_axis and chartdete_module is not None:
with status_placeholder.container():
with st.spinner("πŸ” Detecting axis labels (ChartDete + OCR)..."):
axis_config, detections, ocr_results = detect_axis_calibration(
chartdete_module, img
)
# Update visualization with axis calibration
if show_visualization:
result_img = draw_points_on_image(img, data_series, axis_config)
viz_placeholder.image(cv2.cvtColor(result_img, cv2.COLOR_BGR2RGB), use_container_width=True)
# Clear the status
status_placeholder.empty()
else:
# Clear status if no axis detection
status_placeholder.empty()
# Show axis calibration results
if axis_config is not None:
with axis_placeholder.container():
with st.expander("Axis Calibration (Auto-detected)", expanded=True):
col_x, col_y = st.columns(2)
with col_x:
st.markdown("**X-axis:**")
st.write(f" {axis_config['x1_val']} β†’ {axis_config['x2_val']}")
with col_y:
st.markdown("**Y-axis:**")
st.write(f" {axis_config['y1_val']} β†’ {axis_config['y2_val']}")
# Show OCR details
if ocr_results:
st.markdown("**Detected Labels:**")
ocr_text = []
if 'xlabels' in ocr_results:
x_vals = [f"{l['value']}" for l in ocr_results['xlabels'] if l['value'] is not None]
ocr_text.append(f"X: [{', '.join(x_vals)}]")
if 'ylabels' in ocr_results:
y_vals = [f"{l['value']}" for l in ocr_results['ylabels'] if l['value'] is not None]
ocr_text.append(f"Y: [{', '.join(y_vals)}]")
st.caption(' | '.join(ocr_text))
elif auto_axis:
axis_placeholder.warning("Could not auto-detect axis calibration. Manual calibration needed in WPD/StarryDigitizer.")
# Build StarryDigitizer project
project_json = convert_to_starry_digitizer_format(
data_series, img.shape, axis_config
)
# Build WebPlotDigitizer project
wpd_json = convert_to_wpd_format(
data_series, img.shape, axis_config
)
# Create ZIP file for StarryDigitizer
zip_buffer = create_starry_digitizer_zip(img, project_json)
# Create TAR file for WebPlotDigitizer
base_name = os.path.splitext(uploaded_file.name)[0]
tar_buffer = create_wpd_tar(img, wpd_json, project_name=base_name)
# Generate filenames
timestamp_str = datetime.now().strftime('%Y%m%d-%H%M%S')
zip_filename = f"sd-{timestamp_str}.zip"
tar_filename = f"wpd-{timestamp_str}.tar"
# Download buttons
st.subheader("Download")
col_dl1, col_dl2, col_dl3 = st.columns(3)
with col_dl1:
st.download_button(
label="πŸ“¦ StarryDigitizer (.zip)",
data=zip_buffer.getvalue(),
file_name=zip_filename,
mime="application/zip",
help="ZIP containing image.png + project.json"
)
with col_dl2:
st.download_button(
label="πŸ“Š WebPlotDigitizer (.tar)",
data=tar_buffer.getvalue(),
file_name=tar_filename,
mime="application/x-tar",
help="TAR with project folder structure"
)
with col_dl3:
if show_visualization:
_, buffer = cv2.imencode('.png', result_img)
st.download_button(
label="πŸ–ΌοΈ Visualization (.png)",
data=buffer.tobytes(),
file_name=f"{base_name}_result.png",
mime="image/png"
)
# Show JSON previews
with st.expander("Preview StarryDigitizer project.json"):
json_str = json.dumps(project_json, indent=2, ensure_ascii=False)
if len(json_str) > 5000:
st.code(json_str[:5000] + "\n... (truncated)", language="json")
else:
st.code(json_str, language="json")
with st.expander("Preview WebPlotDigitizer wpd.json"):
wpd_preview = json.dumps(wpd_json, indent=2, ensure_ascii=False)
if len(wpd_preview) > 5000:
st.code(wpd_preview[:5000] + "\n... (truncated)", language="json")
else:
st.code(wpd_preview, language="json")
# Instructions
st.info("""
**StarryDigitizer:** Download ZIP β†’ Open [StarryDigitizer](https://starrydigitizer.vercel.app/) β†’ Load Project
**WebPlotDigitizer:** Download TAR β†’ Open [WPD](https://apps.automeris.io/wpd4/) β†’ File β†’ Load Project (.tar)
""")
if __name__ == "__main__":
main()