Снимка на повреда в PDF протокол с локален модел
Техникът снима пукнатина, теч или счупена врата. Локален модел за зрение описва само видимото и пише чернова на протокол, човек я преглежда и одобрява, а чак тогава се прави PDF. Снимката не напуска машината, а координатите от телефона се изчистват преди модела да я види.
qwen2.5vl:7b (има го в Ollama), по желание gemma4; JSON схема в полето format; изчистване на метаданните и координатите от снимката; местоположение, което пише човек; чернова с воден знак и одобрение от човек преди крайния PDF; WeasyPrint 70.0 с външен достъп, ограничен до вградени данни. Всички данни са измислени.
qwen2.5vl е обновявана преди около година — виж дали няма по-нов модел.01Какво ще научиш
- Как модел за зрение превръща снимка в структуриран текст и кога може да го сбърка.
- Как да принудиш модела да върне точно полетата, които искаш (JSON схема).
- Как да изчистиш метаданните и координатите от снимка, преди да я пипнеш.
- Как да направиш PDF протокол с WeasyPrint и защо текстът от модела се третира като чужд.
- Как да вградиш одобрение от човек: чернова, преглед, краен документ.
02Преди да започнеш
- Машина от класа NVIDIA GB10 (например ASUS Ascent GX10 или DGX Spark) с DGX OS (Ubuntu 24.04 за Arm64) и инсталиран Ollama.
- Терминал на машината — директно или по SSH.
- Python 3.10 или по-нов — толкова иска WeasyPrint 70.0.
- Две-три тестови снимки на повреди, правени от теб, без хора, лица, номера на коли и документи. Поне една нарочно размазана.
- Малко Python — четене на код и стартиране на скрипт.
| Какво | Стойност (проверено на 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 има, но е по-стара линия; не го ползваме. |
| WeasyPrint 70.0 (8.09.2026), Python ≥ 3.10, Pango ≥ 1.44; лиценз BSD | |
| Снимки | Pillow 12.3.0 |
| Друга възможност за PDF | ReportLab 5.0.1 — пишеш документа с код, а не с HTML. Тук не го ползваме. |
03Стъпки
-
Какво ще правим и къде е човекът
Стъпка Кой я прави 1. Снимка + местоположение (етаж, вход, помещение) Техникът, ръчно 2. Изчистване на метаданните и координатите Скриптът 3. Чернова: вид, тежест, видими признаци, препоръка Моделът (локално) 4. Преглед и поправки на JSON Човек 5. Краен PDF, само с име на одобряващия Скриптът, след „да“ от човека Защо така? Моделът греши и измисля. Затова му даваме тясна задача — „опиши видимото“ — и не го питаме за цена, срок и причина. Човекът остава подписващият.
-
Работна папка и пакети
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. -
Изтегли модела
bashollama pull qwen2.5vl:7b ollama listПаметта на GB10 е обща за процесора и видеокартата — моделът от 6 GB се събира лесно, но не пускай няколко големи наведнъж. Искаш друг модел?
ollama pull gemma4:e4b, и смени името в скрипта по-долу. Оставяме Ollama да слуша само на самата машина (така е по подразбиране) — скриптът го вика на127.0.0.1. -
Схемата: точно какво искаме от модела
В полето
formatна заявката към Ollama подаваме JSON схема и така получаваме полетата, които искаме, а не свободен текст. Документацията съветва да опишеш схемата и в самия въпрос — ще го направим в следващата стъпка. Забележи кое липсва: цена, срок и „увереност“. Моделът няма откъде да ги знае.Поле Значение damage_typeвид: leakтеч ·crackпукнатина ·plasterмазилка ·door_windowврата/прозорец ·electricalел. ·otherseveritylow·medium·high— предложение, не решениеsummary,visible_signsедно изречение и списък само с видими признаци recommended_actionследваща стъпка — например „оглед от специалист“ needs_specialistда / не photo_unclearмоделът казва, че снимката не стига за преценка -
Скриптът
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— документът не може да дръпне файл от диска или адрес от мрежата. Мястото го пишеш ти — никога не го „четем“ от снимката. -
Първо пускане: чернова
bash · от ~/foto-protokolpython foto_protokol.py draft leak1.jpg --object "Блок Пример 1" --place "Вход А, етаж 3, стълбище"(Сложи името на твоя файл вместо
leak1.jpg.) Получаваш три файла до снимката:_clean.jpg,_report.jsonи_draft.pdfс рамка „ЧЕРНОВА“. Отвори JSON-а — той е твоята работна версия.Пробвай с размазана снимка: моделът трябва да сложи
photo_unclearнаtrue. ⚠️ Дали го прави надеждно, не сме мерили. -
Проверка на метаданните
Не вярвай, провери: изчистената снимка не бива да има EXIF.
bashpython -c "from PIL import Image; print(dict(Image.open('leak1_clean.jpg').getexif()))"Очакваш
{}. Върху нашата измислена снимка беше точно така (преди чистенето имаше и координати). Оригиналът не се променя — не го качвай никъде, ако не е нужно. -
Човекът решава
Прочети
_report.jsonдо снимката. Поправи вида, тежестта, текстовете; махни всичко, което не виждаш. Акоseverityеhighили има съмнение за конструкция, не чакай PDF — повикай специалист. Моделът е помощник за чернова, а не инженер.✅ПравилоКраен PDF не се прави без име на одобряващия. Командатаfinalго изисква и записва в документа — така се вижда кой е прегледал. -
Краен PDF след одобрение
bashpython foto_protokol.py final leak1_report.json --approved-by "Домоуправител Пример"Получаваш
leak1_final.pdfс името на одобряващия и без воден знак „ЧЕРНОВА“. Отвори го и провери три неща: кирилицата се чете, снимката е на мястото си, в документа няма координати. -
По избор: подсказка според вида повреда
Общият въпрос работи за всичко, но ако техникът знае вида на огледа, едно добавено изречение насочва вниманието на модела. Добавя се към
PROMPT, а схемата остава същата — полетата не се менят.Оглед Добавка към въпроса (пример) Теч и влага „Опиши видимите следи от вода и влага: петна, капки, издута или олющена боя, видима плесен, и докъде стигат на снимката.“ Пукнатини и конструкция „Опиши видимите пукнатини, деформации и оголена или ръждясала арматура. Ако виждаш такива, сложи needs_specialist=true.“ Общи части „Опиши видимите повреди по мазилка, осветление, дограма, пощенски кутии и стълбище.“ ⚠️Какво не питаме моделаНе го питаме дали пукнатината е „активна“, каква е ширината ѝ в милиметри, колко квадратни метра е засегнато, откъде идва водата и колко ще струва ремонтът. От една снимка без мащаб и без история това не се вижда — моделът би го измислил. Тези преценки прави специалист на място. Подсказките по-горе не сме пробвали върху модел. -
По избор: свързване с останалата система
Скриптът е самостоятелен. Ако искаш снимката да идва от телефона и протоколът да стига до домоуправителя, редът е същият, само с повече звена: приемане на снимката (форма или Webhook в n8n, урок 04-01) → изчистване и чернова (този скрипт) → запис на JSON-а в Postgres и на файловете в хранилище (урок 04-112) → одобрение от човек → едва тогава изпращане. Ако протоколът е свързан със сигнал от живущ, сложи номера на сигнала в JSON-а — така в урока за пайплайна за заявки протоколът се закача към заявката. ⚠️ Тази свързка не сме я сглобявали.
-
Сигурност — минимумът
- Ollama слуша само на самата машина; не го отваряй към мрежата.
- Оригиналните снимки и готовите PDF са данни: на машина с ограничен достъп, не в обща папка.
- Скриптът не праща нищо навън. Ако после го вържеш към имейл или към друга система, нека изпращането минава през същото одобрение.
- Не променяй
OnlyData, за да „работи по-лесно“ с външна картинка — това е защитата на документа. - Не слагай в обикновена обща папка и
_report.json— той съдържа описанието и мястото.
04Проверка
python3 --versionе 3.10 или по-нов;weasyprint --infoвърви.ollama listпоказва модела за зрение.- Изчистената снимка има празно EXIF (
{}). - Чернова на поне пет различни снимки; всеки JSON е четим и има всички полета.
- Размазаната снимка е получила
photo_unclearили си го забелязал сам. - В черновата PDF има рамка „ЧЕРНОВА“; кирилицата се вижда като букви, а не като квадрати.
- Краен PDF е направен само след преглед и показва името на одобряващия.
- В нито един документ няма координати; мястото е написано от човек.
Тест
1. Откъде идва местоположението (етаж, вход) в протокола?
2. Защо схемата няма поле за цена на ремонта?
3. Защо първо се вика exif_transpose, а после се прави нов образ от пиксели?
4. Кое е вярно за краен PDF в този урок?
05Какво следва
06Източници
- Ollama: qwen2.5vl 🔒 локално — размери, контекст, вход текст и изображение.
- Ollama: gemma4 🔒 локално — тагове и размери; llava за сравнение.
- Ollama: изображения (Vision) — полето
images, base64 в REST API. - Ollama: структуриран изход — JSON схема в
format. - WeasyPrint в PyPI · първи стъпки и сигурност — версия 70.0, пакети за Linux, шрифтове, доставчик на ресурси.
- ReportLab в PyPI — версия 5.0.1 (друга възможност) · Pillow в PyPI.
- Pillow: ImageOps.exif_transpose · Image.getexif — четене и завъртане по EXIF.