uttapen

پردازش تصویر با مدل‌های vision: خواندن فاکتور و فرم فارسی با خروجی JSON

ارسال تصویر با data URI، گرفتن فیلدهای فاکتور فارسی به‌صورت JSON با json_schema، و نکته‌های عملی برای بالا بردن دقت OCR فارسی — با کد پایتون تست‌شده.

۱۶ شهریور ۱۴۰۵ · ۱۰ دقیقه مطالعه · تیم یوتاپن

تا چند سال پیش خواندن یک فاکتور فارسی از روی عکس یعنی Tesseract با مدل زبان فارسی، کلی پیش‌پردازش تصویر، و بعد چند صد خط regex برای پیدا کردن شمارهٔ فاکتور و مبلغ کل. مدل‌های vision این کار را به یک درخواست API تبدیل کرده‌اند: عکس را می‌فرستی، schema فیلدهایی که می‌خواهی را می‌دهی، JSON تمیز می‌گیری. این مقاله همین مسیر را با فاکتور و فرم فارسی طی می‌کند و بعد دربارهٔ چیزهایی حرف می‌زند که در دمو دیده نمی‌شود: کیفیت OCR فارسی، اعتبارسنجی، و هزینه.

کدام مدل‌ها تصویر می‌گیرند

در کاتالوگ، مدلی که ورودی تصویر قبول می‌کند در فیلد input_modalities مقدار image دارد و در صفحهٔ مدل‌ها با فیلتر vision قابل جدا کردن است. چند گزینهٔ معقول برای فاکتور فارسی:

  • google/gemini-2.5-flash — ارزان، سریع، و برای متن فارسی چاپی دقت خوبی دارد. پیش‌فرض کد این مقاله.
  • openai/gpt-5-mini و openai/gpt-4.1-mini — گزینه‌های کم‌هزینهٔ OpenAI با پشتیبانی کامل json_schema.
  • qwen/qwen3-vl-235b-a22b-instruct — از خانوادهٔ Qwen-VL که برای OCR چندزبانه شناخته شده.
  • anthropic/claude-sonnet-4.5 — برای فرم‌های پیچیده و دست‌نویس معمولاً دقیق‌تر، ولی گران‌تر.

قیمت هر مدل در uttapen_pricing صفحهٔ مدل آمده؛ برای مدل‌هایی که تصویر را جداگانه قیمت می‌گذارند، کلید image_toman هم هست. برای فاکتور معمولی، هزینهٔ هر تصویر بسته به مدل از چند ده تا چند صد تومان است و عدد دقیق هر درخواست در هدر X-Uttapen-Cost-Toman برمی‌گردد.

ارسال تصویر: data URI یا URL

دو راه داری. اگر تصویر روی یک آدرس عمومی است، همان URL را در image_url.url بگذار. اگر فایل روی سرور خودت است (حالت رایج برای فاکتور مشتری)، آن را base64 کن و به شکل data:image/jpeg;base64,... بفرست. حجم بدنهٔ درخواست تا ۲۰ مگابایت مجاز است، ولی عملاً بهتر است تصویر را قبل از ارسال به حدود ۱۶۰۰ پیکسل در ضلع بلند و کیفیت JPEG ۸۵ برسانی: هم سریع‌تر می‌رود، هم توکن کمتری مصرف می‌کند، و برای متن چاپی دقت را کم نمی‌کند.

پارامتر detail را برای فاکتور روی high بگذار؛ مدل تصویر را با وضوح بیشتری تحلیل می‌کند و ارقام ریز (مثل کد ملی یا شمارهٔ فاکتور) درست‌تر خوانده می‌شود. برای دسته‌بندی سادهٔ عکس، low کافی و ارزان‌تر است.

خروجی ساخت‌یافته با json_schema

نکتهٔ کلیدی این مقاله همین است: به‌جای اینکه از مدل بخواهی «فیلدها را بنویس» و بعد خروجی را parse کنی، یک JSON Schema بده و strict: true بگذار. مدل مجبور می‌شود دقیقاً همان ساختار را برگرداند: بدون متن اضافه، بدون فیلد گمشده، با نوع درست. فیلد confidence را هم عمداً در schema گذاشته‌ایم تا مدل خودش بگوید چقدر مطمئن است؛ این عدد برای مسیریابی به بازبینی انسانی حیاتی است.

# invoice.py — استخراج فیلدهای فاکتور فارسی از تصویر با خروجی JSON ساخت‌یافته
import base64, json, mimetypes, os, sys
from openai import OpenAI

client = OpenAI(
    base_url=os.getenv("UTTAPEN_BASE_URL", "https://api.uttapen.ir/v1"),
    api_key=os.environ["UTTAPEN_API_KEY"],
)
MODEL = os.getenv("VISION_MODEL", "google/gemini-2.5-flash")

INVOICE_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "required": ["seller", "invoice_number", "date_jalali", "items", "total_toman", "confidence"],
    "properties": {
        "seller": {"type": "string"},
        "invoice_number": {"type": "string"},
        "date_jalali": {"type": "string", "description": "YYYY/MM/DD با ارقام لاتین"},
        "items": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "required": ["title", "qty", "unit_toman"],
                "properties": {
                    "title": {"type": "string"},
                    "qty": {"type": "number"},
                    "unit_toman": {"type": "integer"},
                },
            },
        },
        "total_toman": {"type": "integer"},
        "confidence": {"type": "number", "description": "۰ تا ۱؛ اگر تصویر ناخوانا بود کم بده"},
    },
}

def to_data_uri(path: str) -> str:
    mime = mimetypes.guess_type(path)[0] or "image/jpeg"
    with open(path, "rb") as f:
        return f"data:{mime};base64," + base64.b64encode(f.read()).decode()

def read_invoice(path: str) -> dict:
    resp = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
             "تو یک OCR فاکتور فارسی هستی. همهٔ اعداد را به ارقام لاتین برگردان، جداکنندهٔ هزارگان را حذف کن، "
             "مبلغ ریالی را به تومان تبدیل کن (÷۱۰). اگر فیلدی خوانا نبود رشتهٔ خالی یا صفر بده و confidence را کم کن."},
            {"role": "user", "content": [
                {"type": "text", "text": "فیلدهای این فاکتور را استخراج کن."},
                {"type": "image_url", "image_url": {"url": to_data_uri(path), "detail": "high"}},
            ]},
        ],
        response_format={"type": "json_schema",
                         "json_schema": {"name": "invoice", "strict": True, "schema": INVOICE_SCHEMA}},
        max_tokens=800,
        temperature=0,
    )
    return json.loads(resp.choices[0].message.content)

if __name__ == "__main__":
    print(json.dumps(read_invoice(sys.argv[1]), ensure_ascii=False, indent=2))

اجرا با python invoice.py factor.jpg. خروجی برای یک فاکتور فروشگاهی سادهٔ فرضی شبیه این است (مقادیر نمونه‌اند):

{
  "seller": "فروشگاه نمونه",
  "invoice_number": "1405-0231",
  "date_jalali": "1405/06/10",
  "items": [
    { "title": "کابل USB-C", "qty": 2, "unit_toman": 185000 }
  ],
  "total_toman": 370000,
  "confidence": 0.92
}

سه نکته دربارهٔ system prompt که تفاوت واقعی ایجاد می‌کند: ارقام لاتین را صریح بخواه (وگرنه گاهی «۱۲۵۰۰» و گاهی «12500» می‌گیری و parse می‌شکند)؛ واحد پول را قطعی کن، چون فاکتورهای ایرانی گاهی ریال و گاهی تومان‌اند و مدل بدون دستور ممکن است حدس بزند؛ و به مدل اجازه بده «نمی‌دانم» بگوید — خالی بهتر از عدد ساختگی است.

کیفیت OCR فارسی — چیزهایی که در دمو نمی‌بینی

مدل‌های vision در خواندن متن چاپی فارسی خوب‌اند ولی بی‌نقص نیستند. تجربهٔ کار با فاکتور و فرم ایرانی چند قاعدهٔ عملی به دست می‌دهد:

  1. وضوح مهم‌تر از مدل است. عکسی که با موبایل از فاکتور مچاله گرفته شده، با هیچ مدلی درست خوانده نمی‌شود. حداقل ۱۵۰۰ پیکسل در ضلع بلند، بدون سایهٔ شدید، و ترجیحاً اسکن. اگر ورودی از کاربر می‌آید، قبل از ارسال یک پیش‌پردازش ساده (چرخش خودکار، افزایش کنتراست) انجام بده.
  2. ارقام فارسی و عربی. «۴» فارسی و «٤» عربی و «4» لاتین سه کاراکتر متفاوت‌اند و بعضی مدل‌ها قاطی‌شان می‌کنند. با درخواست ارقام لاتین در خروجی، این مشکل در سمت تو حل می‌شود؛ ولی در ورودی، اگر فاکتور با فونت غیرمعمول چاپ شده، دقت ارقام را حتماً اعتبارسنجی کن.
  3. نیم‌فاصله و کشیدگی. «می‌شود» با نیم‌فاصله، «می شود» با فاصله و «میشود» بدون فاصله برای انسان یکی‌اند و برای مقایسهٔ رشته‌ای نه. عنوان اقلام را قبل از مقایسه با دیتابیس نرمال کن.
  4. جدول‌ها. برای فاکتورهایی که اقلام در جدول‌اند، به مدل بگو «هر ردیف یک آیتم» و تعداد ردیف‌ها را هم به‌عنوان فیلد بخواه؛ اگر تعداد آیتم‌های برگشتی با آن نخواند، یک ردیف جا افتاده.
  5. دست‌نویس. فرم‌های دست‌نویس فارسی سخت‌ترین حالت‌اند. مدل‌های گران‌تر بهتر عمل می‌کنند، ولی حتی آن‌ها را بدون بازبینی انسانی در حسابداری نگذار.
  6. دو مرحله برای فرم‌های پیچیده. اول از مدل بخواه کل متن تصویر را خط به خط رونویسی کند (بدون schema)، بعد در درخواست دوم از همان متن با schema فیلدها را استخراج کن. گران‌تر است ولی برای فرم‌های چندصفحه‌ای یا با چیدمان عجیب، خطا را محسوس کم می‌کند.

اعتبارسنجی — به JSON اعتماد نکن، چک کن

اینکه خروجی از نظر ساختار درست است، به این معنی نیست که از نظر محتوا درست است. حداقل این‌ها را بعد از هر استخراج اجرا کن:

  • جمع qty × unit_toman اقلام باید با total_toman بخواند (با تلورانس مالیات یا تخفیف اگر در فاکتور هست). اختلاف یعنی یک رقم اشتباه خوانده شده.
  • date_jalali باید با regex ^14\d\d/(0[1-9]|1[0-2])/(0[1-9]|[12]\d|3[01])$ بخواند و روزش در محدودهٔ ماه باشد.
  • confidence زیر یک آستانه (مثلاً ۰٫۸) یعنی مسیر بازبینی انسانی؛ این عدد خوداظهاری مدل است و کامل نیست، ولی به‌طور تجربی با تصاویر بد همبستگی دارد.
  • اگر فاکتور تکراری است (همان شمارهٔ فاکتور و فروشنده)، قبل از ثبت هشدار بده.

این چک‌ها چند خط کدند و نسبت به هزینهٔ یک رقم اشتباه در حسابداری، رایگان.

هزینه و حریم خصوصی

تصویر پس از عبور از gateway ایران و relay، به ارائه‌دهندهٔ مدل می‌رسد. gateway محتوای درخواست — از جمله تصویر — را ذخیره نمی‌کند و فقط متادیتا (مدل، توکن، هزینه) لاگ می‌شود؛ ولی سیاست نگهداری ارائه‌دهندهٔ زیرین دست ما نیست. برای فاکتورهایی که اطلاعات شخصی دارند، مثل کد ملی مشتری، این را در ارزیابی حقوقی پروژه لحاظ کن و اگر لازم است، بخش‌های حساس را قبل از ارسال با یک مستطیل بپوشان.

دربارهٔ هزینه، قبل از ارسال مبلغی به‌عنوان hold از کیف پول کنار گذاشته می‌شود که برای هر تصویر تقریبی محافظه‌کارانه در نظر می‌گیرد؛ بعد از پاسخ، هزینهٔ قطعی جایگزین می‌شود. اگر با موجودی کم 402 گرفتی، دلیلش همین برآورد اولیه است نه هزینهٔ واقعی؛ max_tokens را کوچک نگه دار (برای فاکتور ۸۰۰ کافی است) تا hold هم کوچک بماند. مستندات ورودی تصویر و فایل جزئیات فرمت‌ها و محدودیت‌ها را دارد.

فرم‌ها: همان روش، schema متفاوت

فرم‌های اداری فارسی (درخواست وام، ثبت‌نام، فرم بیمه) از فاکتور دو تفاوت مهم دارند: بخشی از فیلدها تیک یا دایرهٔ انتخابی است و بخشی دست‌نویس. برای تیک‌ها در schema از boolean یا enum استفاده کن و در system prompt بگو «اگر هیچ گزینه‌ای علامت نخورده، null برگردان» — وگرنه مدل تمایل دارد یکی را حدس بزند. برای فیلدهای دست‌نویس، یک فیلد confidence جداگانه به‌ازای هر فیلد بگذار، نه یک عدد کلی؛ در عمل مدل کد ملی چاپی را با اطمینان بالا و آدرس دست‌نویس را با اطمینان پایین می‌خواند و می‌خواهی این دو را جدا مسیریابی کنی.

فرم چندصفحه‌ای را صفحه‌به‌صفحه بفرست و در هر درخواست فقط schema همان صفحه را بده. فرستادن چند تصویر در یک پیام ممکن است، ولی هم hold بزرگ‌تری کنار گذاشته می‌شود و هم مدل بین صفحه‌ها گیج می‌شود. اگر فیلدی بین صفحه‌ها تکرار شده (مثل شمارهٔ پرونده)، بعد از استخراج یکسان بودنش را چک کن؛ این یک اعتبارسنجی رایگان دیگر است.

اندازه‌گیری دقت قبل از اعتماد

قبل از اینکه خروجی مدل را به سیستم حسابداری وصل کنی، یک مجموعهٔ آزمون بساز: پنجاه فاکتور واقعی از فروشنده‌های مختلف که فیلدهایشان را دستی و درست وارد کرده‌ای. اسکریپت را روی همه اجرا کن و برای هر فیلد جداگانه درصد تطابق دقیق را حساب کن. نتیجهٔ معمول این است که شمارهٔ فاکتور و مبلغ کل دقت بالایی دارند و عنوان اقلام پایین‌تر؛ این به تو می‌گوید کدام فیلدها را می‌شود خودکار ثبت کرد و کدام‌ها همیشه باید از بازبینی انسانی بگذرند.

همین مجموعه را برای مقایسهٔ مدل‌ها هم استفاده کن. مدل ارزان‌تر ممکن است در مبلغ کل با مدل گران برابر باشد و فقط در عنوان اقلام عقب بیفتد؛ اگر عنوان اقلام برایت مهم نیست، تفاوت قیمت چندبرابری بی‌دلیل است. مجموعهٔ آزمون را نگه دار و هر بار که مدل یا prompt را عوض کردی دوباره اجرا کن — بدون آن، هر تغییری حدس است.

کِی از مدل vision استفاده نکنی

اگر روزی ده هزار فاکتور با چیدمان یکسان از یک سیستم مشخص می‌گیری، یک OCR سنتی به‌علاوهٔ قالب ثابت احتمالاً ارزان‌تر و قطعی‌تر است. مدل vision جایی می‌درخشد که چیدمان متغیر است، فروشنده‌ها متفاوت‌اند، یا فرم‌ها نیمه‌دست‌نویس‌اند — یعنی همان جایی که regex شکست می‌خورد. ترکیب هر دو هم معقول است: OCR سنتی برای الگوهای شناخته‌شده، مدل vision برای بقیه.

کد این مقاله با تصویر واقعی و schema بالا روی gateway اجرا شده و ساختار درخواست، response_format و هدرهای هزینه همان‌طور که توضیح دادیم کار می‌کنند؛ مقادیر JSON نمونه صرفاً برای نمایش ساختار خروجی‌اند. برای امتحان با فاکتور خودت، یک کلید از داشبورد بساز؛ چند هزار تومان شارژ برای ده‌ها فاکتور کافی است.

مقاله‌های مرتبط

می‌خواهی همین کد را اجرا کنی؟

با شماره موبایل ثبت‌نام کن، کیف پول را شارژ کن و کلید بگیر. ساخت کلید API ←