Подсказка названия по фотографии

Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором LEGO Technic, 8376, 401 шт.». Запрет на выдумывание в промпте
помог: та же вещь стала «красной щёткой для посуды».

Смотрит gemma3:4b. Выбрана замером на настоящих снимках из базы:
  gemma3:4b     3.5 с, отвечает всегда, надписи читает верно
                («Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14»);
  qwen3-vl:8b   11-32 с и часто пустой ответ — это ризонер, ему нужен бюджет
                в 2000+ токенов, и уменьшение картинки не спасает.
Проверялась и гипотеза, что дело в размере снимка: у gemma3 разницы между
1280 и 512 px почти нет, у qwen3-vl время падает с 62 до 41 с, но ответ
всё равно пуст. Картинка всё же уменьшается до 768 px — незачем гонять лишнее.

Генерация тегов чинилась по ходу: на «Стабилизатор 7805 (TO-220)» модель
отвечала массивом объектов с пояснениями, не укладывалась в 256 токенов,
и JSON обрывался на полуслове — теги выходили пустыми. Бюджет поднят до 500,
промпт требует плоский массив строк, а из битого ответа строки теперь
вытаскиваются регуляркой: лучше несколько живых тегов, чем ничего.

Pillow добавлен в зависимости — для уменьшения картинки перед распознаванием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 01:24:49 +03:00
co-authored by Claude Opus 5
parent 01d7f17f89
commit bae5116d77
9 changed files with 329 additions and 8 deletions
+5
View File
@@ -14,5 +14,10 @@ OLLAMA_HOST=http://localhost:11434
# ollama pull bge-m3
# HLAMINGO_EMBED_MODEL=bge-m3
# Модель, которая смотрит на фотографии и подсказывает название вещи.
# Необязательна — пустое значение выключает подсказку:
# ollama pull gemma3:4b
# HLAMINGO_VISION_MODEL=gemma3:4b
# Порт веб-интерфейса.
# HLAMINGO_PORT=5000
+27
View File
@@ -18,6 +18,7 @@
```
ollama pull qwen3:8b разбор фраз и теги
ollama pull bge-m3 поиск по смыслу
ollama pull gemma3:4b подсказка названия по фото (необязательно)
```
### Какую модель выбрать
@@ -102,6 +103,7 @@ start.cmd запускает сервер и печатает адреса
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
@@ -175,6 +177,31 @@ start.cmd запускает сервер и печатает адреса
Модель разбирает фразу за ~1,5 с, места из неё создаются сами.
### Подсказка по фотографии
Когда фото выбрано в форме заведения вещи, приложение показывает его модели
и предлагает название с прочитанными надписями:
```
фото коробки лампы → «Лампочка LED E14»
надписи: Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14
```
Кнопка **«✓ Подставить»** переносит это в поля, **«Не надо»** прячет.
Само оно не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель
назвала «конструктором LEGO Technic, 8376, 401 шт.». Поэтому — только
подсказка, решает человек.
Смотрит **`gemma3:4b`** (`HLAMINGO_VISION_MODEL`, 3,3 ГБ). Выбрана замером
на настоящих снимках: 3,5 с и всегда отвечает, тогда как `qwen3-vl:8b` — 1132 с
и часто пустой ответ, потому что это ризонер, которому нужен бюджет
в 2000+ токенов. Перед отправкой картинка уменьшается до 768 px: снимок
разворачивается в токены, а для чтения крупных надписей столько не нужно.
Распознавание отключается пустым `HLAMINGO_VISION_MODEL` — тогда блок
подсказки просто не появляется.
### Фото
Две кнопки: **«📷 Снять»** открывает камеру, **«🖼»** — готовые снимки в галерее.
+56
View File
@@ -495,7 +495,12 @@ def api_retag_item(item_id):
return jsonify({"ok": False,
"reply": "Модель недоступна — теги придумывать некому."}), 503
# Вторая попытка не роскошь: если только что распознавали фото, в памяти
# Ollama лежит vision-модель, и первый запрос уходит на её выгрузку
# и загрузку текстовой — он может вернуться пустым.
fresh = brain.aliases(item.get("name"), item.get("note"))
if not fresh:
fresh = brain.aliases(item.get("name"), item.get("note"))
if not fresh:
return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."})
@@ -611,6 +616,57 @@ def _drop_photo_file(filename):
logger.warning("Не удалось удалить фото %s: %s", filename, e)
@app.route("/api/recognize", methods=["POST"])
def api_recognize():
"""Что на снимке — подсказка для поля «Предмет».
Ничего не сохраняет: возвращает предложение, а решает человек. Модели
домысливают бренды и артикулы, когда надписей не видно, поэтому запись
вслепую сделала бы базу хуже, а не лучше.
"""
upload = request.files.get("photo")
if not upload or not upload.filename:
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
if not config.VISION_MODEL:
return jsonify({"ok": False, "reply": "Распознавание выключено."}), 503
if not ollama.check():
return jsonify({"ok": False, "reply": "Модель недоступна."}), 503
guess = brain.describe_photo(upload.read())
if not guess:
return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."})
reply = "Похоже на «%s»" % guess["name"]
if guess.get("text"):
reply += ". Надписи: %s" % guess["text"]
if not guess.get("sure"):
reply += " (не уверен — проверь)"
return jsonify({"ok": True, "guess": guess, "reply": reply})
@app.route("/api/item/<item_id>/recognize", methods=["POST"])
def api_recognize_saved(item_id):
"""То же, но для фотографии, которая уже прикреплена к вещи."""
db = store.load()
item = db["items"].get(item_id)
if not item or not item.get("photo"):
return jsonify({"ok": False, "reply": "У этой записи нет фото."}), 404
if not config.VISION_MODEL or not ollama.check():
return jsonify({"ok": False, "reply": "Модель недоступна."}), 503
path = PHOTOS_DIR / item["photo"]
if not path.exists():
return jsonify({"ok": False, "reply": "Файл фотографии не найден."}), 404
guess = brain.describe_photo(path.read_bytes())
if not guess:
return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."})
return jsonify({"ok": True, "guess": guess,
"reply": "На фото похоже на «%s»%s" % (
guess["name"],
(". Надписи: " + guess["text"]) if guess.get("text") else "")})
@app.route("/api/photo", methods=["DELETE"])
def api_delete_photo():
data = request.get_json(silent=True) or {}
+74 -4
View File
@@ -71,7 +71,9 @@ note — уточнение, если есть, иначе null.
ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти.
Верни ТОЛЬКО JSON-массив строк, 3-7 штук, без пояснений.
Верни ТОЛЬКО плоский JSON-массив СТРОК, 3-7 штук: ["первое","второе","третье"].
НЕ объекты, НЕ поля type/function/description только строки. Без пояснений
до и после массива: любой лишний текст ломает разбор.
Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку
сокращений, а для деталей что это по функции.
@@ -286,6 +288,53 @@ def _split_places(text):
return parts
# ============================================================
# Подсказка по фотографии
# ============================================================
LOOK_PROMPT = (
"Посмотри на фото и определи, что это за предмет. Ответь ТОЛЬКО JSON, "
"без пояснений:\n"
'{"name": "...", "text": "...", "sure": true}\n'
"name — как назвал бы вещь человек, который кладёт её в коробку: коротко, "
"по-русски, в именительном падеже. Если на упаковке есть марка и параметры "
"(мощность, размер, цоколь) — включи их в название.\n"
"text — все надписи и маркировка, которые действительно видно на предмете, "
"через запятую. Если надписей нет — пустая строка.\n"
"sure — true, если предмет узнан уверенно; false, если это догадка.\n"
"НЕ ВЫДУМЫВАЙ марки, артикулы и модели, которых не видно на фото: "
"лучше общее название («красная щётка»), чем придуманный артикул."
)
def describe_photo(image_bytes):
"""Что на фотографии: {name, text, sure} или None.
Это подсказка, а не готовая запись: модели склонны домысливать бренды
и артикулы, когда надписей не видно. Проверено на реальных снимках
клипсу для кабеля модель называла «конструктором LEGO Technic 8376».
Поэтому результат всегда показывается человеку на подтверждение.
"""
answer = ollama.look(image_bytes, LOOK_PROMPT)
if not answer:
return None
data = _extract_json(answer)
if not isinstance(data, dict):
# Модель ответила прозой — берём первую строку как название.
line = _strip_thoughts(answer).strip().splitlines()[0] if answer.strip() else ""
line = line.strip(" .;")
return {"name": line[:80], "text": "", "sure": False} if line else None
name = str(data.get("name") or "").strip(" .;")
if not name:
return None
return {
"name": name[:80],
"text": str(data.get("text") or "").strip()[:300],
"sure": bool(data.get("sure", False)),
}
# ============================================================
# Синонимы
# ============================================================
@@ -296,12 +345,33 @@ def aliases(name, note=None, model=None):
return []
model = model or config.CHAT_MODEL
prompt = name + (" (" + note + ")" if note else "")
# 500, а не 256: модель может ответить многословно (объектами вместо строк),
# и тогда на 256 токенах JSON обрывается на полуслове — разбирать нечего.
content = ollama.chat(model, [
{"role": "system", "content": ALIAS_SYSTEM},
{"role": "user", "content": prompt},
], num_predict=256)
data = _extract_json(content, "[", "]") if content else None
return _clean_tags(data, name)
], num_predict=500)
if not content:
return []
tags = _clean_tags(_extract_json(content, "[", "]"), name)
return tags or _salvage_tags(content, name)
_QUOTED_RE = re.compile(r'"([^"\\]{2,60})"')
_SERVICE_WORDS = {"name", "type", "function", "description", "synonym",
"synonyms", "aliases", "tags", "value", "text"}
def _salvage_tags(content, name):
"""Достаёт теги из оборванного или неверно оформленного ответа.
Модель иногда не укладывается в лимит, и JSON приходит битым. Строки в нём
всё равно есть вытаскиваем их, отбрасывая имена полей вроде "name".
Лучше несколько живых тегов, чем ничего.
"""
text = _strip_thoughts(content or "")
found = [m for m in _QUOTED_RE.findall(text) if m.strip().lower() not in _SERVICE_WORDS]
return _clean_tags(found, name)
def _clean_tags(data, name):
+16
View File
@@ -152,6 +152,22 @@ OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip
CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen3:8b"
CHAT_MODEL_ALT = "qwen2.5:7b" # быстрее, но русский хуже
# Модель, которая смотрит на фотографии: подсказывает название вещи и читает
# надписи с упаковки. Пустая строка — распознавание выключено.
#
# gemma3:4b выбрана по замеру на настоящих снимках из базы:
# gemma3:4b 3.5 с, отвечает всегда, надписи читает верно («IEK, C35,
# 9 Вт LED, E14»), занимает 3.3 ГБ;
# qwen3-vl:8b 11-32 с и часто пустой ответ: это ризонер, ему нужен бюджет
# в 2000+ токенов, и даже уменьшение картинки не спасает.
# Обе выдумывают, когда надписей нет, поэтому результат — только подсказка,
# которую человек подтверждает.
VISION_MODEL = os.environ.get("HLAMINGO_VISION_MODEL", "gemma3:4b").strip()
VISION_BUDGET = 400 # хватает на название и список надписей
# Картинку перед отправкой уменьшаем: снимок с телефона разворачивается
# в тысячи токенов, а для чтения крупных надписей столько не нужно.
VISION_MAX_SIDE = 768
# bge-m3 — многоязычная модель, на русском заметно лучше прежней
# nomic-embed-text. Замер на одном и том же складе:
# nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный
+77 -1
View File
@@ -4,8 +4,10 @@
активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов.
"""
import io
import json
import time
import base64
import socket
import logging
import urllib.parse
@@ -29,6 +31,11 @@ logger = logging.getLogger(__name__)
# Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут),
# а после неудачи — пауза, в течение которой к серверу вообще не ходим.
# ============================================================
# Бюджет ответа для моделей, которые всегда размышляют: размышления идут
# в счёт того же лимита, и обычных 512 токенов им не хватает даже на короткий
# ответ. Замер на qwen3-vl: при 200 токенах content пуст, при 400 — уже ответ.
THINKING_BUDGET = 1200
CONNECT_TIMEOUT = 1.5 # столько ждём само соединение
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки
@@ -142,12 +149,81 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS,
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8"))
_mark_up()
return (resp.get("message") or {}).get("content", "")
except Exception as e:
_mark_down()
logger.warning("Запрос к модели %s не удался: %s", model, e)
return None
message = resp.get("message") or {}
content = message.get("content", "")
# Модели вроде qwen3-vl размышляют всегда, и think=False их не выключает:
# весь бюджет уходит в поле thinking, а content остаётся пустым при
# done_reason="length". Один раз повторяем с запасом — иначе такая модель
# выглядит нерабочей, хотя дело только в лимите.
if not content.strip() and message.get("thinking") \
and resp.get("done_reason") == "length" and num_predict < THINKING_BUDGET:
logger.info("Модель %s ушла в размышления — повторяю с бюджетом %d",
model, THINKING_BUDGET)
return chat(model, messages, timeout=timeout,
num_predict=THINKING_BUDGET, think=think)
return content
def look(image_bytes, prompt, model=None, budget=None, timeout=180):
"""Показать модели картинку и получить текстовый ответ. None при неудаче.
Картинка уменьшается перед отправкой: изображение разворачивается в токены,
и снимок с телефона стоит их тысячами без пользы для чтения крупных надписей.
"""
model = model or config.VISION_MODEL
if not model or not image_bytes or not available():
return None
small = _shrink(image_bytes, config.VISION_MAX_SIDE)
payload = {
"model": model,
"stream": False,
"messages": [{"role": "user", "content": prompt,
"images": [base64.b64encode(small).decode("ascii")]}],
"options": {"num_predict": budget or config.VISION_BUDGET, "temperature": 0},
}
req = urllib.request.Request(
config.OLLAMA_HOST + "/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8"))
_mark_up()
return ((resp.get("message") or {}).get("content") or "").strip()
except Exception as e:
_mark_down()
logger.warning("Не удалось распознать фото моделью %s: %s", model, e)
return None
def _shrink(image_bytes, side):
"""Уменьшает картинку по длинной стороне. Без Pillow отдаёт как есть."""
try:
from PIL import Image
except ImportError:
return image_bytes
try:
img = Image.open(io.BytesIO(image_bytes))
img = img.convert("RGB")
if max(img.size) > side:
ratio = side / float(max(img.size))
img = img.resize((max(1, int(img.width * ratio)),
max(1, int(img.height * ratio))), Image.LANCZOS)
out = io.BytesIO()
img.save(out, format="JPEG", quality=85)
return out.getvalue()
except Exception as e:
logger.warning("Не удалось уменьшить картинку: %s", e)
return image_bytes
def embed(text, is_query=False):
"""Вектор текста или None. is_query=True для поисковых запросов.
+1
View File
@@ -1 +1,2 @@
flask
pillow
+44
View File
@@ -209,6 +209,7 @@
<button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button>
</div>
<div id="formPhotoPreview" class="photo" hidden></div>
<div id="guessBox" class="card" hidden></div>
</div>
<div class="mt"><button class="act" id="goBtn">Положить</button></div>
@@ -510,11 +511,53 @@ async function takeFormPhoto(input) {
img.src = URL.createObjectURL(pendingPhoto);
preview.appendChild(img);
$('#formPhotoClear').hidden = false;
recognizePending(); // фото есть — спросим модель, что на нём
} catch (e) {
say('Не смог прочитать это изображение: ' + e);
clearPendingPhoto();
}
}
// Подсказка по фото: показываем, что увидела модель, и даём подставить это
// в поле названия. Само не подставляется — модель домысливает бренды,
// когда надписей не видно.
async function recognizePending() {
if (!pendingPhoto) return;
const box = $('#guessBox');
box.hidden = false;
box.innerHTML = '<div class="meta">Смотрю на фото…</div>';
try {
const fd = new FormData();
fd.append('photo', pendingPhoto, 'photo.jpg');
const d = await api('/api/recognize', {method: 'POST', body: fd});
if (!d.ok) { box.hidden = true; return; }
const g = d.guess;
box.innerHTML = '<div class="meta">На фото похоже на:</div>'
+ '<h3>' + esc(g.name) + '</h3>'
+ (g.text ? '<div class="meta">Надписи: ' + esc(g.text) + '</div>' : '')
+ (g.sure ? '' : '<div class="meta">Модель не уверена — проверьте.</div>');
const bar = document.createElement('div');
bar.className = 'acts';
const use = document.createElement('button');
use.textContent = '✓ Подставить';
use.onclick = () => {
$('#itemName').value = g.name;
if (g.text && !$('#itemNote').value) $('#itemNote').value = g.text;
box.hidden = true;
$('#itemName').focus();
};
const skip = document.createElement('button');
skip.textContent = 'Не надо';
skip.onclick = () => { box.hidden = true; };
bar.appendChild(use);
bar.appendChild(skip);
box.appendChild(bar);
} catch (e) {
box.hidden = true;
}
}
$('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput'));
$('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput'));
@@ -524,6 +567,7 @@ function clearPendingPhoto() {
$('#formPhotoPreview').hidden = true;
$('#formPhotoPreview').innerHTML = '';
$('#formPhotoClear').hidden = true;
$('#guessBox').hidden = true;
}
// Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null.
+29 -3
View File
@@ -116,13 +116,39 @@ r.check("пустое имя — 400",
r.check("занятое имя — 400",
c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 400)
print("\n== Подсказка по фотографии ==")
HAS_VISION = bool(config.VISION_MODEL) and HAS_AI and config.VISION_MODEL in ollama.models()
if HAS_VISION:
real = None
for candidate in ("31ee37dada70.jpg", "afbaf962ad16.jpg"):
path = config.PROJECT_DIR / "static" / "photos" / candidate
if path.exists():
real = path
break
if real:
d = c.post("/api/recognize", content_type="multipart/form-data",
data={"photo": (io.BytesIO(real.read_bytes()), "p.jpg")}).get_json()
r.check("модель что-то увидела", d.get("ok") and d["guess"]["name"], d)
r.check("название не пустое и короткое",
0 < len(d["guess"]["name"]) <= 80, d["guess"]["name"])
print(" предложила: %s" % d["guess"]["name"])
else:
print(" (реальных фото нет — пропускаю)")
else:
print(" (vision-модель недоступна — пропускаю)")
r.check("без файла — 400", c.post("/api/recognize", content_type="multipart/form-data",
data={}).status_code == 400)
r.check("для записи без фото — 404",
c.post("/api/item/нет-такой/recognize").status_code == 404)
print("\n== Пересоздание тегов ==")
if HAS_AI:
j("PATCH", "/api/item/" + item, aliases=["мусорный тег"])
d = c.post("/api/item/" + item + "/retag").get_json()
r.check("теги пересозданы", d.get("ok") and d["item"]["aliases"], d)
r.check("мусорный тег заменён", "мусорный тег" not in d["item"]["aliases"],
d["item"]["aliases"])
r.check("теги пересозданы", d.get("ok") and d.get("item", {}).get("aliases"), d)
r.check("мусорный тег заменён",
"мусорный тег" not in d.get("item", {}).get("aliases", ["мусорный тег"]),
d.get("item"))
d = c.post("/api/item/" + item + "/retag?keep=1").get_json()
r.check("режим дополнения работает", d.get("ok"), d)
r.check("retag для несуществующей — 404",