Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewVERIFIED 2026-10-01 · UPDATED 2026-10-01
IDENTITY
module
GX10-04-108 · Damage photo to PDF report with a local vision-language model
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Intermediate
duration
2–3 h
prerequisites
A GB10-class machine with DGX OS (Ubuntu 24.04, Arm64) and Ollama installed; Python 3.10 or newer; shell access (local or SSH); basic Python
trust_label
VERIFIED 2026-10-01 (Ollama library pages and API docs, PyPI pages, WeasyPrint and Pillow documentation) · UPDATED 2026-10-01 · NOT TESTED end to end (no GB10 machine available during the check; only the metadata-stripping function was run, on a synthetic image with Pillow 12.3.0; WeasyPrint and the model calls were not run)
versions
Ollama model qwen2.5vl:7b (6.0 GB, 125K context, text and image input; library page updated about a year ago) · alternative gemma4 (vision-capable; tags e2b, e4b, 12b, 26b, 31b; default e4b 6.6–9.5 GB) · WeasyPrint 70.0 (released 2026-09-08, Python 3.10+, needs Pango 1.44+) · Pillow 12.3.0 · ReportLab 5.0.1 (alternative, not used)
not_available
pixtral is not in the Ollama library (page returns 404 on 2026-10-01); llava exists but is an older family (library page: version 1.6) and is not used here
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
04-107_Whisper_3CX_Call_Logging.html / 04-109_Signal_Classification_Agent.html
PURPOSE

Turn a technician's photo of building damage into a PDF report draft with a local vision-language model, in four controlled steps: (1) strip all metadata including GPS from the photo and re-encode it; (2) ask the model through Ollama POST /api/chat with the image in the images array, stream false, and a JSON schema in the format field; (3) a person reviews and edits the JSON, and the location is typed by the person, never read from the photo; (4) only after approval render the final PDF with WeasyPrint. The model describes only what is visible; it does not estimate cost or deadlines. All data in the lesson is fictional (building manager "Домоуправител Пример").

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

04-109 · Classifying signals with a local model (04-109_Signal_Classification_Agent.html) · related: 04-110 request pipeline, 04-112 document storage, 04-116 morning dashboard, OCR lessons 04-213 to 04-215 for reading text from documents · series index: kagami.bg/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10arm64vision-language-modelollamaqwen2.5vlweasyprintpdfexifprivacyhuman-in-the-loop
ПРОВЕРЕНО · 01.10.2026 ОБНОВЕНО · 01.10.2026

Снимка на повреда в PDF протокол с локален модел

Техникът снима пукнатина, теч или счупена врата. Локален модел за зрение описва само видимото и пише чернова на протокол, човек я преглежда и одобрява, а чак тогава се прави PDF. Снимката не напуска машината, а координатите от телефона се изчистват преди модела да я види.

⏱ 2–3 ч Средно GX10 NVIDIA GB10 · 20 ядра Arm · 128 GB обща памет Ollama · Python · WeasyPrint
Ollama + модел за зрение (qwen2.5vl)🔒 локално Python · Pillow · WeasyPrint🔒 локално
🔄
ОБНОВЕНО · 01.10.2026 — какво
Урокът е преработен по текущите документи. Махнахме: Pixtral (няма го в библиотеката на Ollama), стария LLaVA като препоръчан избор, цените и срока на ремонта, които моделът „оценяваше“ (не може да ги знае), измислената „увереност“ 0,85, извличането на JSON с регулярен израз, API, отворено към цялата мрежа с разрешени всички източници, пълната схема на база данни, имейл и SMS и калкулацията „колко пари се спестяват“. Добавихме: модел qwen2.5vl:7b (има го в Ollama), по желание gemma4; JSON схема в полето format; изчистване на метаданните и координатите от снимката; местоположение, което пише човек; чернова с воден знак и одобрение от човек преди крайния PDF; WeasyPrint 70.0 с външен достъп, ограничен до вградени данни. Всички данни са измислени.
⚠️
Какво не сме пускали сами
Нямахме машина от класа GB10. Файловете са сверени с официалните страници, но не са тествани от край до край — затова няма етикет „ТЕСТВАНО“. Пуснахме само функцията, която изчиства метаданните, върху измислена снимка (Pillow 12.3.0): в резултата нямаше нито EXIF, нито координати. Не сме пускали: модела върху снимки на повреди, качеството на отговорите му на български, WeasyPrint (в това, че вградената снимка се зарежда през ограничения доставчик на ресурси, също не сме сигурни), скоростта на машината. Страницата на Ollama за qwen2.5vl е обновявана преди около година — виж дали няма по-нов модел.

01Какво ще научиш

02Преди да започнеш

КаквоСтойност (проверено на 01.10.2026)
Моделqwen2.5vl:7b — 6,0 GB, контекст 125K, вход текст и изображение. Страницата му в Ollama е обновявана преди около година. ⚠️ Качество на български не сме мерили.
Друга възможностgemma4 — има зрение; размери e2b, e4b (по подразбиране, 6,6–9,5 GB), 12b, 26b, 31b. Обновява се често, затова виж таговете преди теглене.
Няма гоpixtral — страницата в библиотеката на Ollama връща „не е намерена“. llava има, но е по-стара линия; не го ползваме.
PDFWeasyPrint 70.0 (8.09.2026), Python ≥ 3.10, Pango ≥ 1.44; лиценз BSD
СнимкиPillow 12.3.0
Друга възможност за PDFReportLab 5.0.1 — пишеш документа с код, а не с HTML. Тук не го ползваме.
🛡️
Поверителност: снимката е данни
Снимка от телефон често носи метаданни — марка на апарата, час и географски координати. Затова първата стъпка е да ги изчистим. Не снимай хора, лица, регистрационни номера и документи; ако все пак попаднат в кадъра, не ги пращай никому. ⚠️ Това е добра практика, не правен съвет — за лични данни питай отговорния за защитата им при теб.
🧪
Данните са измислени
Сградата „Блок Пример 1“, техникът „Техник Пример“ и домоуправителят „Домоуправител Пример“ са въображаеми. Така можеш да пробваш всичко без истински адреси и хора.

03Стъпки

  1. Какво ще правим и къде е човекът

    СтъпкаКой я прави
    1. Снимка + местоположение (етаж, вход, помещение)Техникът, ръчно
    2. Изчистване на метаданните и координатитеСкриптът
    3. Чернова: вид, тежест, видими признаци, препоръкаМоделът (локално)
    4. Преглед и поправки на JSONЧовек
    5. Краен PDF, само с име на одобряващияСкриптът, след „да“ от човека

    Защо така? Моделът греши и измисля. Затова му даваме тясна задача — „опиши видимото“ — и не го питаме за цена, срок и причина. Човекът остава подписващият.

  2. Работна папка и пакети

    bash · на машината
    python3 --version
    mkdir -p ~/foto-protokol && cd ~/foto-protokol
    python3 -m venv venv
    . venv/bin/activate
    pip install weasyprint==70.0 pillow
    weasyprint --info

    Версията е закачена. WeasyPrint ползва системната библиотека Pango; на Debian и Ubuntu по документацията му трябват пакетите libpango-1.0-0, libpangoft2-1.0-0 и libharfbuzz-subset0. Ако в PDF вместо букви излязат квадрати, инсталирай шрифтове с кирилица в системата — документацията го пише като честа причина. ⚠️ Не сме го пускали на GB10.

  3. Изтегли модела

    bash
    ollama pull qwen2.5vl:7b
    ollama list

    Паметта на GB10 е обща за процесора и видеокартата — моделът от 6 GB се събира лесно, но не пускай няколко големи наведнъж. Искаш друг модел? ollama pull gemma4:e4b, и смени името в скрипта по-долу. Оставяме Ollama да слуша само на самата машина (така е по подразбиране) — скриптът го вика на 127.0.0.1.

  4. Схемата: точно какво искаме от модела

    В полето format на заявката към Ollama подаваме JSON схема и така получаваме полетата, които искаме, а не свободен текст. Документацията съветва да опишеш схемата и в самия въпрос — ще го направим в следващата стъпка. Забележи кое липсва: цена, срок и „увереност“. Моделът няма откъде да ги знае.

    ПолеЗначение
    damage_typeвид: leak теч · crack пукнатина · plaster мазилка · door_window врата/прозорец · electrical ел. · other
    severitylow · medium · high — предложение, не решение
    summary, visible_signsедно изречение и списък само с видими признаци
    recommended_actionследваща стъпка — например „оглед от специалист“
    needs_specialistда / не
    photo_unclearмоделът казва, че снимката не стига за преценка
  5. Скриптът foto_protokol.py

    Един файл с две команди: draft (чернова) и final (краен PDF след одобрение). Прочети го по-бавно — тук са всички важни решения.

    python · ~/foto-protokol/foto_protokol.py
    #!/usr/bin/env python3
    """Снимка на повреда -> чернова -> одобрение от човек -> PDF."""
    import argparse, base64, html, io, json, sys, urllib.request
    from datetime import date
    from pathlib import Path
    from string import Template
    
    from PIL import Image, ImageOps
    from weasyprint import HTML
    from weasyprint.urls import URLFetcher
    
    OLLAMA = "http://127.0.0.1:11434"
    MODEL = "qwen2.5vl:7b"
    
    TYPES = {"leak": "теч", "crack": "пукнатина", "plaster": "мазилка",
             "door_window": "врата / прозорец", "electrical": "електрическа", "other": "друга"}
    LEVELS = {"low": "ниска", "medium": "средна", "high": "висока"}
    
    SCHEMA = {
        "type": "object",
        "properties": {
            "damage_type": {"type": "string", "enum": list(TYPES)},
            "severity": {"type": "string", "enum": list(LEVELS)},
            "summary": {"type": "string"},
            "visible_signs": {"type": "array", "items": {"type": "string"}},
            "recommended_action": {"type": "string"},
            "needs_specialist": {"type": "boolean"},
            "photo_unclear": {"type": "boolean"},
        },
        "required": ["damage_type", "severity", "summary", "visible_signs",
                     "recommended_action", "needs_specialist", "photo_unclear"],
    }
    
    PROMPT = (
        "Ти помагаш на техник, който оглежда жилищен блок. Опиши САМО това, което се вижда "
        "на снимката. Не гадай причини, не пиши цени и срокове. Ако снимката е неясна, "
        "сложи photo_unclear=true. Пиши на български. Върни JSON по тази схема: "
        + json.dumps(SCHEMA, ensure_ascii=False)
    )
    
    
    def clean_photo(path):
        """Връща JPEG без EXIF и без координати."""
        img = ImageOps.exif_transpose(Image.open(path)).convert("RGB")  # първо завъртане по EXIF
        clean = Image.frombytes("RGB", img.size, img.tobytes())          # нов образ само от пиксели
        clean.thumbnail((1280, 1280))
        out = io.BytesIO()
        clean.save(out, "JPEG", quality=88)
        return out.getvalue()
    
    
    def ask_model(jpeg):
        body = {
            "model": MODEL, "stream": False, "format": SCHEMA,
            "options": {"temperature": 0},
            "messages": [{"role": "user", "content": PROMPT,
                          "images": [base64.b64encode(jpeg).decode()]}],
        }
        req = urllib.request.Request(OLLAMA + "/api/chat", json.dumps(body).encode(),
                                     {"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=300) as r:
            return json.loads(json.loads(r.read())["message"]["content"])
    
    
    class OnlyData(URLFetcher):
        """Само вградени данни; нищо от мрежата и от диска."""
        def fetch(self, url, headers=None):
            if not url.startswith("data:"):
                raise ValueError("външни ресурси не се зареждат")
            return super().fetch(url, headers)
    
    
    PAGE = Template("""<html lang="bg"><meta charset="utf-8"><style>
    @page { size: A4; margin: 18mm; }
    body { font-family: sans-serif; font-size: 11pt; color: #111; }
    h1 { font-size: 18pt; margin: 0 0 4mm; }
    .mark { padding: 3mm; border: 1px solid #b45309; color: #b45309; font-weight: bold; margin-bottom: 5mm; }
    table { border-collapse: collapse; width: 100%; margin: 4mm 0; }
    td { border: 1px solid #999; padding: 2mm 3mm; vertical-align: top; }
    td:first-child { width: 38mm; background: #f2f2f2; }
    img { max-width: 100%; max-height: 90mm; margin-top: 3mm; }
    </style>
    <h1>Протокол за повреда</h1>
    <div class="mark">$status</div>
    <table>
    <tr><td>Обект</td><td>$obj</td></tr>
    <tr><td>Място</td><td>$place</td></tr>
    <tr><td>Дата</td><td>$date</td></tr>
    <tr><td>Вид повреда</td><td>$dtype</td></tr>
    <tr><td>Тежест (предложение)</td><td>$level</td></tr>
    <tr><td>Описание</td><td>$summary</td></tr>
    <tr><td>Видими признаци</td><td><ul>$signs</ul></td></tr>
    <tr><td>Препоръка</td><td>$action</td></tr>
    <tr><td>Нужен специалист</td><td>$spec</td></tr>
    </table>
    <img src="$photo">
    <p>Чернова от локален модел ($model). Моделът може да греши; отговорен е човекът, който одобрява.</p>
    </html>""")
    
    
    def render_pdf(rep, jpeg, out_path, approved_by=None):
        e = html.escape  # текстът от модела е чужд: екранираме го
        status = ("ОДОБРЕН от: " + e(approved_by)) if approved_by \
            else "ЧЕРНОВА — още не е одобрена от човек"
        if rep.get("photo_unclear"):
            status += " · снимката е неясна, нужна е нова"
        page = PAGE.substitute(
            status=status, obj=e(rep["object"]), place=e(rep["place"]), date=e(rep["date"]),
            dtype=e(TYPES.get(rep["damage_type"], rep["damage_type"])),
            level=e(LEVELS.get(rep["severity"], rep["severity"])),
            summary=e(rep["summary"]),
            signs="".join("<li>" + e(s) + "</li>" for s in rep["visible_signs"]),
            action=e(rep["recommended_action"]),
            spec="да" if rep["needs_specialist"] else "не",
            photo="data:image/jpeg;base64," + base64.b64encode(jpeg).decode(),
            model=e(rep["model"]))
        HTML(string=page, url_fetcher=OnlyData()).write_pdf(str(out_path))
    
    
    def main():
        ap = argparse.ArgumentParser()
        sub = ap.add_subparsers(dest="cmd", required=True)
        d = sub.add_parser("draft")
        d.add_argument("photo")
        d.add_argument("--object", required=True, help="напр. Блок Пример 1")
        d.add_argument("--place", required=True, help="етаж / вход / помещение — пишеш го ти")
        f = sub.add_parser("final")
        f.add_argument("report_json")
        f.add_argument("--approved-by", required=True)
        a = ap.parse_args()
    
        if a.cmd == "draft":
            stem = Path(a.photo).with_suffix("")
            jpeg = clean_photo(a.photo)
            rep = ask_model(jpeg)
            rep.update(object=a.object, place=a.place, date=str(date.today()), model=MODEL)
            Path(f"{stem}_clean.jpg").write_bytes(jpeg)
            Path(f"{stem}_report.json").write_text(
                json.dumps(rep, ensure_ascii=False, indent=2), encoding="utf-8")
            render_pdf(rep, jpeg, f"{stem}_draft.pdf")
            print("Чернова готова. Прегледай и поправи", f"{stem}_report.json")
        else:
            rj = Path(a.report_json)
            rep = json.loads(rj.read_text(encoding="utf-8"))
            jpeg = Path(str(rj).replace("_report.json", "_clean.jpg")).read_bytes()
            render_pdf(rep, jpeg, str(rj).replace("_report.json", "_final.pdf"), a.approved_by)
            print("Краен PDF записан.")
    
    
    if __name__ == "__main__":
        sys.exit(main())
    💡
    Защо точно така
    exif_transpose първо — телефонът записва завъртането в метаданните; ако ги изхвърлиш без това, снимката излиза легнала. Нов образ от пиксели — не пренася нищо от старата снимка, включително координатите. temperature: 0 — по-малко разнообразие между две пускания. html.escape — ако в отговора на модела има код или таг, той ще се покаже като текст, а не ще се изпълни. OnlyData — документът не може да дръпне файл от диска или адрес от мрежата. Мястото го пишеш ти — никога не го „четем“ от снимката.
  6. Първо пускане: чернова

    bash · от ~/foto-protokol
    python foto_protokol.py draft leak1.jpg --object "Блок Пример 1" --place "Вход А, етаж 3, стълбище"

    (Сложи името на твоя файл вместо leak1.jpg.) Получаваш три файла до снимката: _clean.jpg, _report.json и _draft.pdf с рамка „ЧЕРНОВА“. Отвори JSON-а — той е твоята работна версия.

    Пробвай с размазана снимка: моделът трябва да сложи photo_unclear на true. ⚠️ Дали го прави надеждно, не сме мерили.

  7. Проверка на метаданните

    Не вярвай, провери: изчистената снимка не бива да има EXIF.

    bash
    python -c "from PIL import Image; print(dict(Image.open('leak1_clean.jpg').getexif()))"

    Очакваш {}. Върху нашата измислена снимка беше точно така (преди чистенето имаше и координати). Оригиналът не се променя — не го качвай никъде, ако не е нужно.

  8. Човекът решава

    Прочети _report.json до снимката. Поправи вида, тежестта, текстовете; махни всичко, което не виждаш. Ако severity е high или има съмнение за конструкция, не чакай PDF — повикай специалист. Моделът е помощник за чернова, а не инженер.

    ✅
    Правило
    Краен PDF не се прави без име на одобряващия. Командата final го изисква и записва в документа — така се вижда кой е прегледал.
  9. Краен PDF след одобрение

    bash
    python foto_protokol.py final leak1_report.json --approved-by "Домоуправител Пример"

    Получаваш leak1_final.pdf с името на одобряващия и без воден знак „ЧЕРНОВА“. Отвори го и провери три неща: кирилицата се чете, снимката е на мястото си, в документа няма координати.

  10. По избор: подсказка според вида повреда

    Общият въпрос работи за всичко, но ако техникът знае вида на огледа, едно добавено изречение насочва вниманието на модела. Добавя се към PROMPT, а схемата остава същата — полетата не се менят.

    ОгледДобавка към въпроса (пример)
    Теч и влага„Опиши видимите следи от вода и влага: петна, капки, издута или олющена боя, видима плесен, и докъде стигат на снимката.“
    Пукнатини и конструкция„Опиши видимите пукнатини, деформации и оголена или ръждясала арматура. Ако виждаш такива, сложи needs_specialist=true.“
    Общи части„Опиши видимите повреди по мазилка, осветление, дограма, пощенски кутии и стълбище.“
    ⚠️
    Какво не питаме модела
    Не го питаме дали пукнатината е „активна“, каква е ширината ѝ в милиметри, колко квадратни метра е засегнато, откъде идва водата и колко ще струва ремонтът. От една снимка без мащаб и без история това не се вижда — моделът би го измислил. Тези преценки прави специалист на място. Подсказките по-горе не сме пробвали върху модел.
  11. По избор: свързване с останалата система

    Скриптът е самостоятелен. Ако искаш снимката да идва от телефона и протоколът да стига до домоуправителя, редът е същият, само с повече звена: приемане на снимката (форма или Webhook в n8n, урок 04-01) → изчистване и чернова (този скрипт) → запис на JSON-а в Postgres и на файловете в хранилище (урок 04-112) → одобрение от човек → едва тогава изпращане. Ако протоколът е свързан със сигнал от живущ, сложи номера на сигнала в JSON-а — така в урока за пайплайна за заявки протоколът се закача към заявката. ⚠️ Тази свързка не сме я сглобявали.

  12. Сигурност — минимумът

    • Ollama слуша само на самата машина; не го отваряй към мрежата.
    • Оригиналните снимки и готовите PDF са данни: на машина с ограничен достъп, не в обща папка.
    • Скриптът не праща нищо навън. Ако после го вържеш към имейл или към друга система, нека изпращането минава през същото одобрение.
    • Не променяй OnlyData, за да „работи по-лесно“ с външна картинка — това е защитата на документа.
    • Не слагай в обикновена обща папка и _report.json — той съдържа описанието и мястото.

04Проверка

Тест

1. Откъде идва местоположението (етаж, вход) в протокола?

2. Защо схемата няма поле за цена на ремонта?

3. Защо първо се вика exif_transpose, а после се прави нов образ от пиксели?

4. Кое е вярно за краен PDF в този урок?

05Какво следва

06Източници

  1. Ollama: qwen2.5vl 🔒 локално — размери, контекст, вход текст и изображение.
  2. Ollama: gemma4 🔒 локално — тагове и размери; llava за сравнение.
  3. Ollama: изображения (Vision) — полето images, base64 в REST API.
  4. Ollama: структуриран изход — JSON схема в format.
  5. WeasyPrint в PyPI · първи стъпки и сигурност — версия 70.0, пакети за Linux, шрифтове, доставчик на ресурси.
  6. ReportLab в PyPI — версия 5.0.1 (друга възможност) · Pillow в PyPI.
  7. Pillow: ImageOps.exif_transpose · Image.getexif — четене и завъртане по EXIF.