Подсказка названия по фотографии
Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором 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:
@@ -14,5 +14,10 @@ OLLAMA_HOST=http://localhost:11434
|
|||||||
# ollama pull bge-m3
|
# ollama pull bge-m3
|
||||||
# HLAMINGO_EMBED_MODEL=bge-m3
|
# HLAMINGO_EMBED_MODEL=bge-m3
|
||||||
|
|
||||||
|
# Модель, которая смотрит на фотографии и подсказывает название вещи.
|
||||||
|
# Необязательна — пустое значение выключает подсказку:
|
||||||
|
# ollama pull gemma3:4b
|
||||||
|
# HLAMINGO_VISION_MODEL=gemma3:4b
|
||||||
|
|
||||||
# Порт веб-интерфейса.
|
# Порт веб-интерфейса.
|
||||||
# HLAMINGO_PORT=5000
|
# HLAMINGO_PORT=5000
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
```
|
```
|
||||||
ollama pull qwen3:8b разбор фраз и теги
|
ollama pull qwen3:8b разбор фраз и теги
|
||||||
ollama pull bge-m3 поиск по смыслу
|
ollama pull bge-m3 поиск по смыслу
|
||||||
|
ollama pull gemma3:4b подсказка названия по фото (необязательно)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Какую модель выбрать
|
### Какую модель выбрать
|
||||||
@@ -102,6 +103,7 @@ start.cmd запускает сервер и печатает адреса
|
|||||||
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
|
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
|
||||||
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
|
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
|
||||||
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
|
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
|
||||||
|
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
|
||||||
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
|
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
|
||||||
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
|
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
|
||||||
|
|
||||||
@@ -175,6 +177,31 @@ start.cmd запускает сервер и печатает адреса
|
|||||||
|
|
||||||
Модель разбирает фразу за ~1,5 с, места из неё создаются сами.
|
Модель разбирает фразу за ~1,5 с, места из неё создаются сами.
|
||||||
|
|
||||||
|
### Подсказка по фотографии
|
||||||
|
|
||||||
|
Когда фото выбрано в форме заведения вещи, приложение показывает его модели
|
||||||
|
и предлагает название с прочитанными надписями:
|
||||||
|
|
||||||
|
```
|
||||||
|
фото коробки лампы → «Лампочка LED E14»
|
||||||
|
надписи: Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14
|
||||||
|
```
|
||||||
|
|
||||||
|
Кнопка **«✓ Подставить»** переносит это в поля, **«Не надо»** прячет.
|
||||||
|
Само оно не подставляется, и это принципиально: модели домысливают марки
|
||||||
|
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель
|
||||||
|
назвала «конструктором LEGO Technic, 8376, 401 шт.». Поэтому — только
|
||||||
|
подсказка, решает человек.
|
||||||
|
|
||||||
|
Смотрит **`gemma3:4b`** (`HLAMINGO_VISION_MODEL`, 3,3 ГБ). Выбрана замером
|
||||||
|
на настоящих снимках: 3,5 с и всегда отвечает, тогда как `qwen3-vl:8b` — 11–32 с
|
||||||
|
и часто пустой ответ, потому что это ризонер, которому нужен бюджет
|
||||||
|
в 2000+ токенов. Перед отправкой картинка уменьшается до 768 px: снимок
|
||||||
|
разворачивается в токены, а для чтения крупных надписей столько не нужно.
|
||||||
|
|
||||||
|
Распознавание отключается пустым `HLAMINGO_VISION_MODEL` — тогда блок
|
||||||
|
подсказки просто не появляется.
|
||||||
|
|
||||||
### Фото
|
### Фото
|
||||||
|
|
||||||
Две кнопки: **«📷 Снять»** открывает камеру, **«🖼»** — готовые снимки в галерее.
|
Две кнопки: **«📷 Снять»** открывает камеру, **«🖼»** — готовые снимки в галерее.
|
||||||
|
|||||||
@@ -495,6 +495,11 @@ def api_retag_item(item_id):
|
|||||||
return jsonify({"ok": False,
|
return jsonify({"ok": False,
|
||||||
"reply": "Модель недоступна — теги придумывать некому."}), 503
|
"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"))
|
fresh = brain.aliases(item.get("name"), item.get("note"))
|
||||||
if not fresh:
|
if not fresh:
|
||||||
return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."})
|
return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."})
|
||||||
@@ -611,6 +616,57 @@ def _drop_photo_file(filename):
|
|||||||
logger.warning("Не удалось удалить фото %s: %s", filename, e)
|
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"])
|
@app.route("/api/photo", methods=["DELETE"])
|
||||||
def api_delete_photo():
|
def api_delete_photo():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
|||||||
+74
-4
@@ -71,7 +71,9 @@ note — уточнение, если есть, иначе null.
|
|||||||
|
|
||||||
ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти.
|
ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти.
|
||||||
|
|
||||||
Верни ТОЛЬКО JSON-массив строк, 3-7 штук, без пояснений.
|
Верни ТОЛЬКО плоский JSON-массив СТРОК, 3-7 штук: ["первое","второе","третье"].
|
||||||
|
НЕ объекты, НЕ поля type/function/description — только строки. Без пояснений
|
||||||
|
до и после массива: любой лишний текст ломает разбор.
|
||||||
Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку
|
Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку
|
||||||
сокращений, а для деталей — что это по функции.
|
сокращений, а для деталей — что это по функции.
|
||||||
|
|
||||||
@@ -286,6 +288,53 @@ def _split_places(text):
|
|||||||
return parts
|
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 []
|
return []
|
||||||
model = model or config.CHAT_MODEL
|
model = model or config.CHAT_MODEL
|
||||||
prompt = name + (" (" + note + ")" if note else "")
|
prompt = name + (" (" + note + ")" if note else "")
|
||||||
|
# 500, а не 256: модель может ответить многословно (объектами вместо строк),
|
||||||
|
# и тогда на 256 токенах JSON обрывается на полуслове — разбирать нечего.
|
||||||
content = ollama.chat(model, [
|
content = ollama.chat(model, [
|
||||||
{"role": "system", "content": ALIAS_SYSTEM},
|
{"role": "system", "content": ALIAS_SYSTEM},
|
||||||
{"role": "user", "content": prompt},
|
{"role": "user", "content": prompt},
|
||||||
], num_predict=256)
|
], num_predict=500)
|
||||||
data = _extract_json(content, "[", "]") if content else None
|
if not content:
|
||||||
return _clean_tags(data, name)
|
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):
|
def _clean_tags(data, name):
|
||||||
|
|||||||
@@ -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 = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen3:8b"
|
||||||
CHAT_MODEL_ALT = "qwen2.5:7b" # быстрее, но русский хуже
|
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 — многоязычная модель, на русском заметно лучше прежней
|
# bge-m3 — многоязычная модель, на русском заметно лучше прежней
|
||||||
# nomic-embed-text. Замер на одном и том же складе:
|
# nomic-embed-text. Замер на одном и том же складе:
|
||||||
# nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный
|
# nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный
|
||||||
|
|||||||
+77
-1
@@ -4,8 +4,10 @@
|
|||||||
активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов.
|
активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
import base64
|
||||||
import socket
|
import socket
|
||||||
import logging
|
import logging
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -29,6 +31,11 @@ logger = logging.getLogger(__name__)
|
|||||||
# Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут),
|
# Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут),
|
||||||
# а после неудачи — пауза, в течение которой к серверу вообще не ходим.
|
# а после неудачи — пауза, в течение которой к серверу вообще не ходим.
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
# Бюджет ответа для моделей, которые всегда размышляют: размышления идут
|
||||||
|
# в счёт того же лимита, и обычных 512 токенов им не хватает даже на короткий
|
||||||
|
# ответ. Замер на qwen3-vl: при 200 токенах content пуст, при 400 — уже ответ.
|
||||||
|
THINKING_BUDGET = 1200
|
||||||
|
|
||||||
CONNECT_TIMEOUT = 1.5 # столько ждём само соединение
|
CONNECT_TIMEOUT = 1.5 # столько ждём само соединение
|
||||||
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
|
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
|
||||||
UP_CACHE = 5.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:
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
resp = json.loads(r.read().decode("utf-8"))
|
resp = json.loads(r.read().decode("utf-8"))
|
||||||
_mark_up()
|
_mark_up()
|
||||||
return (resp.get("message") or {}).get("content", "")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_mark_down()
|
_mark_down()
|
||||||
logger.warning("Запрос к модели %s не удался: %s", model, e)
|
logger.warning("Запрос к модели %s не удался: %s", model, e)
|
||||||
return None
|
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):
|
def embed(text, is_query=False):
|
||||||
"""Вектор текста или None. is_query=True для поисковых запросов.
|
"""Вектор текста или None. is_query=True для поисковых запросов.
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
flask
|
flask
|
||||||
|
pillow
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
<button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button>
|
<button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="formPhotoPreview" class="photo" hidden></div>
|
<div id="formPhotoPreview" class="photo" hidden></div>
|
||||||
|
<div id="guessBox" class="card" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt"><button class="act" id="goBtn">Положить</button></div>
|
<div class="mt"><button class="act" id="goBtn">Положить</button></div>
|
||||||
@@ -510,11 +511,53 @@ async function takeFormPhoto(input) {
|
|||||||
img.src = URL.createObjectURL(pendingPhoto);
|
img.src = URL.createObjectURL(pendingPhoto);
|
||||||
preview.appendChild(img);
|
preview.appendChild(img);
|
||||||
$('#formPhotoClear').hidden = false;
|
$('#formPhotoClear').hidden = false;
|
||||||
|
recognizePending(); // фото есть — спросим модель, что на нём
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
say('Не смог прочитать это изображение: ' + e);
|
say('Не смог прочитать это изображение: ' + e);
|
||||||
clearPendingPhoto();
|
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'));
|
$('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput'));
|
||||||
$('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput'));
|
$('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput'));
|
||||||
|
|
||||||
@@ -524,6 +567,7 @@ function clearPendingPhoto() {
|
|||||||
$('#formPhotoPreview').hidden = true;
|
$('#formPhotoPreview').hidden = true;
|
||||||
$('#formPhotoPreview').innerHTML = '';
|
$('#formPhotoPreview').innerHTML = '';
|
||||||
$('#formPhotoClear').hidden = true;
|
$('#formPhotoClear').hidden = true;
|
||||||
|
$('#guessBox').hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null.
|
// Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null.
|
||||||
|
|||||||
+29
-3
@@ -116,13 +116,39 @@ r.check("пустое имя — 400",
|
|||||||
r.check("занятое имя — 400",
|
r.check("занятое имя — 400",
|
||||||
c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 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== Пересоздание тегов ==")
|
print("\n== Пересоздание тегов ==")
|
||||||
if HAS_AI:
|
if HAS_AI:
|
||||||
j("PATCH", "/api/item/" + item, aliases=["мусорный тег"])
|
j("PATCH", "/api/item/" + item, aliases=["мусорный тег"])
|
||||||
d = c.post("/api/item/" + item + "/retag").get_json()
|
d = c.post("/api/item/" + item + "/retag").get_json()
|
||||||
r.check("теги пересозданы", d.get("ok") and d["item"]["aliases"], d)
|
r.check("теги пересозданы", d.get("ok") and d.get("item", {}).get("aliases"), d)
|
||||||
r.check("мусорный тег заменён", "мусорный тег" not in d["item"]["aliases"],
|
r.check("мусорный тег заменён",
|
||||||
d["item"]["aliases"])
|
"мусорный тег" not in d.get("item", {}).get("aliases", ["мусорный тег"]),
|
||||||
|
d.get("item"))
|
||||||
d = c.post("/api/item/" + item + "/retag?keep=1").get_json()
|
d = c.post("/api/item/" + item + "/retag?keep=1").get_json()
|
||||||
r.check("режим дополнения работает", d.get("ok"), d)
|
r.check("режим дополнения работает", d.get("ok"), d)
|
||||||
r.check("retag для несуществующей — 404",
|
r.check("retag для несуществующей — 404",
|
||||||
|
|||||||
Reference in New Issue
Block a user