first commit
This commit is contained in:
commit
578cc3cbf2
6 changed files with 1319 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Локальная модель TrOCR (большие веса — не коммитим)
|
||||||
|
/trocr/
|
||||||
|
|
||||||
|
# Данные разметки и кэш модели
|
||||||
|
/data/images/
|
||||||
|
/data/labels*.tsv
|
||||||
|
/data/autolabel_*.json
|
||||||
|
/data/hf_cache/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir flask==3.0.3 gunicorn==22.0.0
|
||||||
|
# TrOCR auto-labeling deps (CPU build of torch keeps the image smaller).
|
||||||
|
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
RUN pip install --no-cache-dir transformers pillow sentencepiece
|
||||||
|
|
||||||
|
# Persist the downloaded HuggingFace model in the mounted /data volume.
|
||||||
|
ENV HF_HOME=/data/hf_cache
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
COPY templates/ templates/
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Single worker + threads: the TrOCR model is loaded once per process and the
|
||||||
|
# auto-label job tracks progress in-process, so we keep one worker.
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--threads", "4", "--timeout", "120", "app:app"]
|
||||||
156
README.md
Normal file
156
README.md
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
# OCR Annotator
|
||||||
|
|
||||||
|
Веб-инструмент для разметки кропов текста. Показывает изображение, вы вводите текст,
|
||||||
|
результат сохраняется в `labels.tsv` — готовый формат для дообучения TrOCR.
|
||||||
|
|
||||||
|
## Быстрый старт через Docker
|
||||||
|
|
||||||
|
### 1. Установите Docker и Docker Compose (если ещё нет)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu / Debian
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y docker.io docker-compose-plugin
|
||||||
|
|
||||||
|
# Добавьте себя в группу docker (чтобы не писать sudo)
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
newgrp docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Скачайте / разархивируйте проект
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Перейдите в папку проекта
|
||||||
|
cd ocr-annotator
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Положите ваши кропы в папку data/images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/images
|
||||||
|
|
||||||
|
# Скопируйте ваши кропы:
|
||||||
|
cp /path/to/your/crops/*.jpg data/images/
|
||||||
|
# или
|
||||||
|
cp -r /path/to/your/crops/. data/images/
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддерживаемые форматы: `jpg`, `jpeg`, `png`, `bmp`, `tiff`, `webp`
|
||||||
|
|
||||||
|
### 4. Запустите
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте браузер: **http://localhost:5000**
|
||||||
|
|
||||||
|
При следующих запусках (образ уже собран):
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
Остановить:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 Авто-разметка (TrOCR)
|
||||||
|
|
||||||
|
В шапке рабочего экрана есть кнопка **🤖 Авто-разметка**. Она прогоняет все
|
||||||
|
**неразмеченные** картинки текущей подпапки через модель TrOCR и сохраняет
|
||||||
|
предсказания с пометкой «авто» — то есть **требуется ручная перепроверка**.
|
||||||
|
|
||||||
|
- Авто-метки в списке файлов и в счётчике отмечены оранжевым и значком 🤖.
|
||||||
|
- Фильтр **🤖 Проверить** показывает только авто-метки, ожидающие проверки.
|
||||||
|
- Как только вы вручную сохраняете такую метку (Enter / «Сохранить»), пометка
|
||||||
|
«авто» снимается — метка становится проверенной.
|
||||||
|
- Прогресс показывается в статус-баре; разметка идёт в фоне, можно продолжать
|
||||||
|
проверять уже готовые картинки.
|
||||||
|
|
||||||
|
Модель задаётся переменной `TROCR_MODEL` (по умолчанию `raxtemur/trocr-base-ru`
|
||||||
|
— TrOCR, дообученная на русский). Можно указать любую HuggingFace-модель типа
|
||||||
|
`VisionEncoderDecoder`, например `microsoft/trocr-base-printed`. При первом
|
||||||
|
запуске модель скачивается из HuggingFace и кэшируется в `data/hf_cache`
|
||||||
|
(переменная `HF_HOME`), поэтому повторные запуски быстрые.
|
||||||
|
|
||||||
|
> В экспортируемый `labels.tsv` пометка «авто» **не попадает** — там остаётся
|
||||||
|
> чистый формат `имя_файла<TAB>текст` для дообучения TrOCR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Горячие клавиши
|
||||||
|
|
||||||
|
| Клавиша | Действие |
|
||||||
|
|---------------|-----------------------------|
|
||||||
|
| `Enter` | Сохранить и перейти дальше |
|
||||||
|
| `Tab` | Пропустить (без сохранения) |
|
||||||
|
| `Alt + →` | Следующее изображение |
|
||||||
|
| `Alt + ←` | Предыдущее изображение |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
Файл `data/labels.tsv` — табуляция-разделённый файл:
|
||||||
|
|
||||||
|
```
|
||||||
|
crop_001.jpg Иванов И.И.
|
||||||
|
crop_002.jpg ул. Ленина, д. 5
|
||||||
|
crop_003.jpg СЧЁТ-ФАКТУРА
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачать через интерфейс: кнопка **⬇ Экспорт TSV**, или забрать прямо из папки `data/labels.tsv`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Использование для дообучения TrOCR
|
||||||
|
|
||||||
|
```python
|
||||||
|
from datasets import Dataset
|
||||||
|
from PIL import Image
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# Читаем разметку
|
||||||
|
df = pd.read_csv("data/labels.tsv", sep="\t", header=None, names=["file_name", "text"])
|
||||||
|
df["image"] = df["file_name"].apply(lambda f: Image.open(f"data/images/{f}").convert("RGB"))
|
||||||
|
|
||||||
|
dataset = Dataset.from_pandas(df[["image", "text"]])
|
||||||
|
# Далее — стандартный fine-tuning TrOCR через HuggingFace Trainer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Запуск без Docker (если нужно)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install flask gunicorn
|
||||||
|
# для кнопки «🤖 Авто-разметка» дополнительно:
|
||||||
|
pip install torch transformers pillow sentencepiece
|
||||||
|
|
||||||
|
export IMAGES_DIR=./data/images
|
||||||
|
export OUTPUT_FILE=./data/labels.tsv
|
||||||
|
export TROCR_MODEL=raxtemur/trocr-base-ru # необязательно
|
||||||
|
|
||||||
|
python app.py
|
||||||
|
# или через gunicorn:
|
||||||
|
gunicorn --bind 0.0.0.0:5000 app:app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
ocr-annotator/
|
||||||
|
├── app.py # Flask backend
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html # UI
|
||||||
|
└── data/
|
||||||
|
├── images/ # ← кладите сюда кропы
|
||||||
|
└── labels.tsv # ← сюда пишется разметка
|
||||||
|
```
|
||||||
373
app.py
Normal file
373
app.py
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Flask, render_template, request, jsonify, send_file, send_from_directory
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
IMAGES_DIR = Path(os.environ.get("IMAGES_DIR", "/data/images"))
|
||||||
|
DATA_DIR = Path(os.environ.get("OUTPUT_FILE", "/data/labels.tsv")).parent
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp"}
|
||||||
|
|
||||||
|
# TrOCR model used for automatic pre-labeling (Russian-finetuned by default).
|
||||||
|
TROCR_MODEL = os.environ.get("TROCR_MODEL", "raxtemur/trocr-base-ru")
|
||||||
|
|
||||||
|
_locks = {}
|
||||||
|
_locks_mutex = threading.Lock()
|
||||||
|
|
||||||
|
# Lazily-loaded TrOCR model, shared across requests in the worker process.
|
||||||
|
_model_cache = {}
|
||||||
|
_model_mutex = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_lock(subfolder):
|
||||||
|
with _locks_mutex:
|
||||||
|
if subfolder not in _locks:
|
||||||
|
_locks[subfolder] = threading.Lock()
|
||||||
|
return _locks[subfolder]
|
||||||
|
|
||||||
|
|
||||||
|
def get_subfolders():
|
||||||
|
if not IMAGES_DIR.exists():
|
||||||
|
return []
|
||||||
|
subs = []
|
||||||
|
for d in sorted(IMAGES_DIR.iterdir()):
|
||||||
|
if d.is_dir():
|
||||||
|
has_images = any(
|
||||||
|
f.suffix.lower() in SUPPORTED_EXTENSIONS for f in d.iterdir() if f.is_file()
|
||||||
|
)
|
||||||
|
if has_images:
|
||||||
|
subs.append(d.name)
|
||||||
|
return subs
|
||||||
|
|
||||||
|
|
||||||
|
def get_images_in(subfolder):
|
||||||
|
folder = IMAGES_DIR / subfolder
|
||||||
|
if not folder.exists():
|
||||||
|
return []
|
||||||
|
return sorted([
|
||||||
|
f.name for f in folder.iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def labels_path(subfolder):
|
||||||
|
return DATA_DIR / f"labels_{subfolder}.tsv"
|
||||||
|
|
||||||
|
|
||||||
|
def load_labels(subfolder):
|
||||||
|
"""Return {filename: {"text": str, "auto": bool}}.
|
||||||
|
|
||||||
|
TSV format: filename<TAB>text[<TAB>"auto"]. A third column "auto" marks
|
||||||
|
labels produced by the automatic pre-labeling pass (need a human recheck).
|
||||||
|
Rows with two columns are treated as human-verified for backward compat.
|
||||||
|
"""
|
||||||
|
path = labels_path(subfolder)
|
||||||
|
labels = {}
|
||||||
|
if path.exists():
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for row in csv.reader(f, delimiter="\t"):
|
||||||
|
if len(row) >= 2:
|
||||||
|
auto = len(row) >= 3 and row[2] == "auto"
|
||||||
|
labels[row[0]] = {"text": row[1], "auto": auto}
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def save_labels(subfolder, labels):
|
||||||
|
path = labels_path(subfolder)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8", newline="") as f:
|
||||||
|
writer = csv.writer(f, delimiter="\t")
|
||||||
|
for filename, entry in sorted(labels.items()):
|
||||||
|
row = [filename, entry["text"]]
|
||||||
|
if entry.get("auto"):
|
||||||
|
row.append("auto")
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
subfolders = get_subfolders()
|
||||||
|
return render_template("index.html", subfolders=subfolders)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/subfolders")
|
||||||
|
def api_subfolders():
|
||||||
|
subs = get_subfolders()
|
||||||
|
result = []
|
||||||
|
for s in subs:
|
||||||
|
images = get_images_in(s)
|
||||||
|
labels = load_labels(s)
|
||||||
|
labeled = sum(1 for img in images if img in labels)
|
||||||
|
auto = sum(1 for img in images if labels.get(img, {}).get("auto"))
|
||||||
|
result.append({"name": s, "total": len(images), "labeled": labeled, "auto": auto})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/images/<subfolder>")
|
||||||
|
def api_images(subfolder):
|
||||||
|
images = get_images_in(subfolder)
|
||||||
|
labels = load_labels(subfolder)
|
||||||
|
return jsonify([
|
||||||
|
{
|
||||||
|
"filename": img,
|
||||||
|
"labeled": img in labels,
|
||||||
|
"text": labels.get(img, {}).get("text", ""),
|
||||||
|
"auto": labels.get(img, {}).get("auto", False),
|
||||||
|
}
|
||||||
|
for img in images
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/label/<subfolder>", methods=["POST"])
|
||||||
|
def api_label(subfolder):
|
||||||
|
data = request.json
|
||||||
|
filename = data.get("filename")
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return jsonify({"error": "filename required"}), 400
|
||||||
|
|
||||||
|
images = get_images_in(subfolder)
|
||||||
|
if filename not in images:
|
||||||
|
return jsonify({"error": "image not found"}), 404
|
||||||
|
|
||||||
|
lock = get_lock(subfolder)
|
||||||
|
with lock:
|
||||||
|
labels = load_labels(subfolder)
|
||||||
|
if text == "" and filename in labels:
|
||||||
|
del labels[filename]
|
||||||
|
else:
|
||||||
|
# A manual save always counts as human-verified (clears the auto flag).
|
||||||
|
labels[filename] = {"text": text, "auto": False}
|
||||||
|
save_labels(subfolder, labels)
|
||||||
|
|
||||||
|
images_after = get_images_in(subfolder)
|
||||||
|
labels_after = load_labels(subfolder)
|
||||||
|
labeled = sum(1 for img in images_after if img in labels_after)
|
||||||
|
return jsonify({"ok": True, "labeled": labeled, "total": len(images_after)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/delete/<subfolder>", methods=["POST"])
|
||||||
|
def api_delete(subfolder):
|
||||||
|
data = request.json
|
||||||
|
filename = data.get("filename")
|
||||||
|
|
||||||
|
if not filename:
|
||||||
|
return jsonify({"error": "filename required"}), 400
|
||||||
|
|
||||||
|
# Validate: must be inside the subfolder, no path traversal
|
||||||
|
image_path = (IMAGES_DIR / subfolder / filename).resolve()
|
||||||
|
allowed_root = (IMAGES_DIR / subfolder).resolve()
|
||||||
|
if not str(image_path).startswith(str(allowed_root)):
|
||||||
|
return jsonify({"error": "invalid path"}), 400
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({"error": "file not found"}), 404
|
||||||
|
|
||||||
|
lock = get_lock(subfolder)
|
||||||
|
with lock:
|
||||||
|
# Remove from disk
|
||||||
|
image_path.unlink()
|
||||||
|
# Remove label if exists
|
||||||
|
labels = load_labels(subfolder)
|
||||||
|
if filename in labels:
|
||||||
|
del labels[filename]
|
||||||
|
save_labels(subfolder, labels)
|
||||||
|
|
||||||
|
images_after = get_images_in(subfolder)
|
||||||
|
labels_after = load_labels(subfolder)
|
||||||
|
labeled = sum(1 for img in images_after if img in labels_after)
|
||||||
|
return jsonify({"ok": True, "labeled": labeled, "total": len(images_after)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/images/<subfolder>/<path:filename>")
|
||||||
|
def serve_image(subfolder, filename):
|
||||||
|
return send_from_directory(IMAGES_DIR / subfolder, filename)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/export")
|
||||||
|
def api_export():
|
||||||
|
"""Export a ZIP: labeled images + labels.tsv"""
|
||||||
|
subfolders = get_subfolders()
|
||||||
|
rows = []
|
||||||
|
image_paths = [] # (arc_name, fs_path)
|
||||||
|
|
||||||
|
for sub in subfolders:
|
||||||
|
labels = load_labels(sub)
|
||||||
|
for filename, entry in sorted(labels.items()):
|
||||||
|
text = entry["text"]
|
||||||
|
fs_path = IMAGES_DIR / sub / filename
|
||||||
|
if fs_path.exists():
|
||||||
|
arc_name = f"{sub}/{filename}"
|
||||||
|
rows.append((arc_name, text))
|
||||||
|
image_paths.append((arc_name, fs_path))
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return jsonify({"error": "No labeled images yet"}), 404
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
# Write TSV
|
||||||
|
tsv_buf = io.StringIO()
|
||||||
|
writer = csv.writer(tsv_buf, delimiter="\t")
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(row)
|
||||||
|
zf.writestr("labels.tsv", tsv_buf.getvalue().encode("utf-8"))
|
||||||
|
|
||||||
|
# Write images
|
||||||
|
for arc_name, fs_path in image_paths:
|
||||||
|
zf.write(fs_path, arc_name)
|
||||||
|
|
||||||
|
buf.seek(0)
|
||||||
|
return send_file(
|
||||||
|
buf,
|
||||||
|
mimetype="application/zip",
|
||||||
|
as_attachment=True,
|
||||||
|
download_name="dataset.zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stats")
|
||||||
|
def api_stats():
|
||||||
|
subs = get_subfolders()
|
||||||
|
total = labeled = auto = 0
|
||||||
|
for s in subs:
|
||||||
|
imgs = get_images_in(s)
|
||||||
|
lbls = load_labels(s)
|
||||||
|
total += len(imgs)
|
||||||
|
labeled += sum(1 for img in imgs if img in lbls)
|
||||||
|
auto += sum(1 for img in imgs if lbls.get(img, {}).get("auto"))
|
||||||
|
return jsonify({"total": total, "labeled": labeled, "remaining": total - labeled, "auto": auto})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Automatic pre-labeling with TrOCR ───────────────────────────────────────
|
||||||
|
|
||||||
|
def autolabel_status_path(subfolder):
|
||||||
|
return DATA_DIR / f"autolabel_{subfolder}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def read_autolabel_status(subfolder):
|
||||||
|
"""Progress is persisted to a file so any gunicorn worker can read it."""
|
||||||
|
path = autolabel_status_path(subfolder)
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_autolabel_status(subfolder, status):
|
||||||
|
path = autolabel_status_path(subfolder)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(status), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def get_trocr():
|
||||||
|
"""Load (once) and return the TrOCR processor + model. Heavy imports are
|
||||||
|
done lazily so the app still runs in environments without torch installed."""
|
||||||
|
with _model_mutex:
|
||||||
|
if "model" not in _model_cache:
|
||||||
|
import torch
|
||||||
|
from transformers import TrOCRProcessor, VisionEncoderDecoderModel
|
||||||
|
|
||||||
|
processor = TrOCRProcessor.from_pretrained(TROCR_MODEL)
|
||||||
|
model = VisionEncoderDecoderModel.from_pretrained(TROCR_MODEL)
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
model.to(device)
|
||||||
|
model.eval()
|
||||||
|
_model_cache.update(
|
||||||
|
{"processor": processor, "model": model, "device": device, "torch": torch}
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_model_cache["processor"],
|
||||||
|
_model_cache["model"],
|
||||||
|
_model_cache["device"],
|
||||||
|
_model_cache["torch"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_autolabel(subfolder):
|
||||||
|
"""Background job: run TrOCR over every unlabeled image in the subfolder and
|
||||||
|
store the predictions flagged as `auto` (i.e. needing a human recheck)."""
|
||||||
|
try:
|
||||||
|
images = get_images_in(subfolder)
|
||||||
|
existing = load_labels(subfolder)
|
||||||
|
todo = [img for img in images if img not in existing]
|
||||||
|
write_autolabel_status(
|
||||||
|
subfolder, {"state": "running", "total": len(todo), "done": 0, "error": None}
|
||||||
|
)
|
||||||
|
if not todo:
|
||||||
|
write_autolabel_status(
|
||||||
|
subfolder, {"state": "done", "total": 0, "done": 0, "error": None}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
processor, model, device, torch = get_trocr()
|
||||||
|
lock = get_lock(subfolder)
|
||||||
|
done = 0
|
||||||
|
for img in todo:
|
||||||
|
try:
|
||||||
|
fs_path = IMAGES_DIR / subfolder / img
|
||||||
|
image = Image.open(fs_path).convert("RGB")
|
||||||
|
pixel_values = processor(images=image, return_tensors="pt").pixel_values.to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
generated_ids = model.generate(pixel_values, max_length=64)
|
||||||
|
text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0].strip()
|
||||||
|
except Exception:
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
# Save incrementally and never clobber a label a human added meanwhile.
|
||||||
|
with lock:
|
||||||
|
current = load_labels(subfolder)
|
||||||
|
if img not in current:
|
||||||
|
current[img] = {"text": text, "auto": True}
|
||||||
|
save_labels(subfolder, current)
|
||||||
|
|
||||||
|
done += 1
|
||||||
|
write_autolabel_status(
|
||||||
|
subfolder,
|
||||||
|
{"state": "running", "total": len(todo), "done": done, "error": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
write_autolabel_status(
|
||||||
|
subfolder, {"state": "done", "total": len(todo), "done": done, "error": None}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
write_autolabel_status(
|
||||||
|
subfolder, {"state": "error", "total": 0, "done": 0, "error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/autolabel/<subfolder>", methods=["POST"])
|
||||||
|
def api_autolabel(subfolder):
|
||||||
|
if subfolder not in get_subfolders():
|
||||||
|
return jsonify({"error": "unknown subfolder"}), 404
|
||||||
|
|
||||||
|
status = read_autolabel_status(subfolder)
|
||||||
|
if status and status.get("state") == "running":
|
||||||
|
return jsonify({"error": "already running", "status": status}), 409
|
||||||
|
|
||||||
|
write_autolabel_status(
|
||||||
|
subfolder, {"state": "running", "total": 0, "done": 0, "error": None}
|
||||||
|
)
|
||||||
|
threading.Thread(target=run_autolabel, args=(subfolder,), daemon=True).start()
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/autolabel/<subfolder>/status")
|
||||||
|
def api_autolabel_status(subfolder):
|
||||||
|
status = read_autolabel_status(subfolder) or {"state": "idle"}
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||||
16
docker-compose.yml
Normal file
16
docker-compose.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
services:
|
||||||
|
annotator:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "5010:5000"
|
||||||
|
volumes:
|
||||||
|
- ./data/images:/data/images # read-write: нужно для удаления файлов
|
||||||
|
- ./data:/data # сюда пишется labels_*.tsv
|
||||||
|
- ./trocr:/models/trocr:ro # локальная (оффлайн) модель TrOCR
|
||||||
|
environment:
|
||||||
|
- IMAGES_DIR=/data/images
|
||||||
|
- OUTPUT_FILE=/data/labels.tsv
|
||||||
|
# Локальная модель для кнопки «🤖 Авто-разметка» (путь внутри контейнера).
|
||||||
|
- TROCR_MODEL=/models/trocr
|
||||||
|
- HF_HOME=/data/hf_cache
|
||||||
|
restart: unless-stopped
|
||||||
741
templates/index.html
Normal file
741
templates/index.html
Normal file
|
|
@ -0,0 +1,741 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OCR Annotator</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Unbounded:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d0d0d; --surface: #161616; --surface2: #1f1f1f;
|
||||||
|
--border: #2a2a2a; --accent: #e8ff3d; --accent2: #3dffc8;
|
||||||
|
--text: #e8e8e8; --muted: #555; --danger: #ff4d4d; --labeled: #3dffc8;
|
||||||
|
--warn: #ffb13d;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: 'JetBrains Mono', monospace; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ── LOBBY ── */
|
||||||
|
#lobby {
|
||||||
|
min-height: 100vh; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: 32px; padding: 40px;
|
||||||
|
}
|
||||||
|
.lobby-logo { font-family: 'Unbounded', sans-serif; font-size: 22px; font-weight: 700; color: var(--accent); letter-spacing: 0.08em; }
|
||||||
|
.lobby-logo span { color: var(--muted); }
|
||||||
|
.lobby-sub { font-size: 11px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
.folder-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; width: 100%; max-width: 800px; }
|
||||||
|
.folder-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
|
||||||
|
padding: 20px; cursor: pointer; transition: all 0.15s; position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.folder-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||||
|
.folder-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--accent); transform: scaleX(0); transition: transform 0.2s; transform-origin: left; }
|
||||||
|
.folder-card:hover::before { transform: scaleX(1); }
|
||||||
|
.folder-name { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; word-break: break-all; }
|
||||||
|
.folder-bar { height: 2px; background: var(--border); border-radius: 1px; margin-bottom: 8px; overflow: hidden; }
|
||||||
|
.folder-bar-fill { height: 100%; background: var(--accent2); border-radius: 1px; transition: width 0.4s; }
|
||||||
|
.folder-stats { font-size: 10px; color: var(--muted); display: flex; justify-content: space-between; }
|
||||||
|
.folder-stats .done { color: var(--accent2); }
|
||||||
|
.no-folders { text-align: center; color: var(--muted); font-size: 12px; line-height: 1.8; }
|
||||||
|
|
||||||
|
/* ── APP SHELL ── */
|
||||||
|
#app { display: none; grid-template-rows: auto 1fr auto; min-height: 100vh; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||||
|
padding: 12px 20px; display: flex; align-items: center; gap: 16px;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
|
||||||
|
padding: 5px 10px; border: 1px solid var(--border); background: transparent;
|
||||||
|
color: var(--muted); cursor: pointer; border-radius: 2px; letter-spacing: 0.05em;
|
||||||
|
transition: all 0.15s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.back-btn:hover { border-color: var(--text); color: var(--text); }
|
||||||
|
.logo { font-family: 'Unbounded', sans-serif; font-size: 12px; font-weight: 700; color: var(--accent); letter-spacing: 0.08em; white-space: nowrap; }
|
||||||
|
.logo span { color: var(--muted); }
|
||||||
|
.subfolder-tag { font-size: 10px; color: var(--muted); background: var(--surface2); border: 1px solid var(--border); padding: 3px 8px; border-radius: 2px; }
|
||||||
|
.progress-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.progress-bar { height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); transition: width 0.4s; border-radius: 1px; }
|
||||||
|
.progress-text { font-size: 10px; color: var(--muted); }
|
||||||
|
.progress-text strong { color: var(--accent); }
|
||||||
|
.header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||||
|
.btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600;
|
||||||
|
letter-spacing: 0.06em; padding: 7px 14px; border: 1px solid var(--border);
|
||||||
|
background: transparent; color: var(--text); cursor: pointer; text-transform: uppercase;
|
||||||
|
transition: all 0.15s; border-radius: 2px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.btn-accent { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||||
|
.btn-accent:hover { background: #fff; border-color: #fff; color: #000; }
|
||||||
|
.btn-danger { border-color: var(--danger); color: var(--danger); }
|
||||||
|
.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||||
|
.btn-warn { border-color: var(--warn); color: var(--warn); }
|
||||||
|
.btn-warn:hover { background: var(--warn); color: #000; border-color: var(--warn); }
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn:disabled:hover { border-color: var(--border); color: var(--text); background: transparent; }
|
||||||
|
|
||||||
|
.auto-hint {
|
||||||
|
font-size: 10px; color: var(--warn); letter-spacing: 0.05em;
|
||||||
|
white-space: nowrap; border: 1px solid var(--warn); border-radius: 2px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
#recheck-text { color: var(--warn); }
|
||||||
|
|
||||||
|
/* Confirm dialog */
|
||||||
|
.confirm-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 200;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
.confirm-box {
|
||||||
|
background: var(--surface); border: 1px solid var(--danger); border-radius: 4px;
|
||||||
|
padding: 28px 32px; max-width: 380px; width: 90%; text-align: center;
|
||||||
|
}
|
||||||
|
.confirm-icon { font-size: 32px; margin-bottom: 12px; }
|
||||||
|
.confirm-title { font-family: 'Unbounded', sans-serif; font-size: 13px; color: var(--text); margin-bottom: 8px; }
|
||||||
|
.confirm-sub { font-size: 11px; color: var(--muted); margin-bottom: 24px; line-height: 1.6; word-break: break-all; }
|
||||||
|
.confirm-btns { display: flex; gap: 10px; justify-content: center; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ── MAIN ── */
|
||||||
|
main { display: grid; grid-template-columns: 240px 1fr; overflow: hidden; height: calc(100vh - 54px - 42px); }
|
||||||
|
|
||||||
|
/* SIDEBAR */
|
||||||
|
.sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 9px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.filter-btns { display: flex; gap: 3px; }
|
||||||
|
.filter-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 3px 6px;
|
||||||
|
border: 1px solid var(--border); background: transparent; color: var(--muted);
|
||||||
|
cursor: pointer; border-radius: 2px; letter-spacing: 0.04em; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.filter-btn.active { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.image-list { flex: 1; overflow-y: auto; padding: 6px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
|
.image-item {
|
||||||
|
padding: 7px 9px; border-radius: 2px; cursor: pointer; font-size: 10px; color: var(--muted);
|
||||||
|
display: flex; align-items: center; gap: 7px; border: 1px solid transparent;
|
||||||
|
margin-bottom: 2px; transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.image-item:hover { background: var(--surface2); color: var(--text); }
|
||||||
|
.image-item.active { background: var(--surface2); border-color: var(--accent); color: var(--text); }
|
||||||
|
.image-item.labeled { color: var(--labeled); }
|
||||||
|
.image-item.labeled .dot { background: var(--labeled); }
|
||||||
|
.image-item.auto { color: var(--warn); }
|
||||||
|
.image-item.auto .dot { background: var(--warn); }
|
||||||
|
.item-badge { font-size: 9px; flex-shrink: 0; }
|
||||||
|
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); flex-shrink: 0; }
|
||||||
|
.item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* PANEL */
|
||||||
|
.panel { display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
/* IMAGE AREA */
|
||||||
|
.image-area {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg); position: relative; overflow: hidden; cursor: grab;
|
||||||
|
}
|
||||||
|
.image-area:active { cursor: grabbing; }
|
||||||
|
.image-area::before {
|
||||||
|
content: ''; position: absolute; inset: 0;
|
||||||
|
background-image: linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||||||
|
background-size: 28px 28px; opacity: 0.25; pointer-events: none;
|
||||||
|
}
|
||||||
|
.zoom-controls {
|
||||||
|
position: absolute; top: 12px; right: 12px; z-index: 10;
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
}
|
||||||
|
.zoom-btn {
|
||||||
|
width: 28px; height: 28px; border: 1px solid var(--border); background: var(--surface);
|
||||||
|
color: var(--text); font-size: 14px; cursor: pointer; border-radius: 2px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: all 0.15s; font-family: monospace;
|
||||||
|
}
|
||||||
|
.zoom-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.zoom-level {
|
||||||
|
font-size: 9px; color: var(--muted); text-align: center;
|
||||||
|
font-family: 'JetBrains Mono', monospace; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The zoomable canvas */
|
||||||
|
#zoom-canvas {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transform-origin: center center;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.img-frame { border: 1px solid var(--border); background: var(--surface2); padding: 4px; box-shadow: 0 0 40px rgba(0,0,0,0.7); }
|
||||||
|
#current-image { display: block; max-width: 800px; max-height: 280px; object-fit: contain; image-rendering: pixelated; }
|
||||||
|
|
||||||
|
.empty-state { text-align: center; color: var(--muted); z-index: 1; }
|
||||||
|
.empty-state .icon { font-size: 40px; margin-bottom: 12px; }
|
||||||
|
.empty-state p { font-size: 11px; line-height: 1.7; }
|
||||||
|
|
||||||
|
/* INPUT */
|
||||||
|
.input-area { background: var(--surface); border-top: 1px solid var(--border); padding: 14px 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.input-row { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.input-label { font-size: 10px; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; }
|
||||||
|
#text-input {
|
||||||
|
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 15px; padding: 9px 13px;
|
||||||
|
border-radius: 2px; outline: none; transition: border-color 0.15s; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
#text-input:focus { border-color: var(--accent); }
|
||||||
|
#text-input::placeholder { color: var(--muted); }
|
||||||
|
.actions-row { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.nav-btns { display: flex; gap: 8px; }
|
||||||
|
.hotkeys { font-size: 9px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.hotkey { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.key { background: var(--surface2); border: 1px solid var(--border); border-radius: 2px; padding: 2px 5px; font-size: 9px; }
|
||||||
|
|
||||||
|
/* FOOTER */
|
||||||
|
footer {
|
||||||
|
background: var(--surface); border-top: 1px solid var(--border);
|
||||||
|
padding: 0 20px; height: 42px; display: flex; align-items: center;
|
||||||
|
justify-content: space-between; font-size: 10px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--labeled); margin-right: 6px; animation: pulse 2s infinite; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||||
|
#current-filename { color: var(--muted); }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 56px; right: 20px; background: var(--accent); color: #000;
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 600;
|
||||||
|
padding: 9px 16px; border-radius: 2px; letter-spacing: 0.05em;
|
||||||
|
transform: translateY(16px); opacity: 0; transition: all 0.2s; z-index: 100; pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast.show { transform: translateY(0); opacity: 1; }
|
||||||
|
.toast.error { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- LOBBY -->
|
||||||
|
<div id="lobby">
|
||||||
|
<div style="text-align:center">
|
||||||
|
<div class="lobby-logo">OCR<span>//</span>Annotator</div>
|
||||||
|
<div class="lobby-sub" style="margin-top:6px">Выберите папку для разметки</div>
|
||||||
|
</div>
|
||||||
|
<div class="folder-grid" id="folder-grid">
|
||||||
|
<div class="no-folders">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--muted);text-align:center">
|
||||||
|
Каждый пользователь выбирает свою подпапку — разметка сохраняется независимо.<br>
|
||||||
|
При экспорте все подпапки объединяются в ZIP (картинки + labels.tsv).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- APP -->
|
||||||
|
<div id="app">
|
||||||
|
<header>
|
||||||
|
<button class="back-btn" onclick="goLobby()">← Папки</button>
|
||||||
|
<div class="logo">OCR<span>//</span>Annotator</div>
|
||||||
|
<div class="subfolder-tag" id="subfolder-tag">—</div>
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-bar"><div class="progress-fill" id="progress-fill" style="width:0%"></div></div>
|
||||||
|
<div class="progress-text"><strong id="labeled-count">0</strong> / <span id="total-count">0</span> размечено<span id="recheck-text"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-warn" id="autolabel-btn" onclick="startAutolabel()" title="Авто-разметить все неразмеченные картинки моделью TrOCR (требует перепроверки)">🤖 Авто-разметка</button>
|
||||||
|
<button class="btn btn-danger" onclick="confirmDelete()">🗑 Удалить</button>
|
||||||
|
<button class="btn" onclick="exportLabels()">⬇ Экспорт ZIP</button>
|
||||||
|
<button class="btn btn-accent" onclick="saveAndNext()">Сохранить →</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<span>Файлы</span>
|
||||||
|
<div class="filter-btns">
|
||||||
|
<button class="filter-btn active" onclick="setFilter('all',this)">Все</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('unlabeled',this)">Нет</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('auto',this)">🤖 Проверить</button>
|
||||||
|
<button class="filter-btn" onclick="setFilter('labeled',this)">Готовы</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-list" id="image-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="image-area" id="image-area">
|
||||||
|
<!-- zoom controls -->
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button class="zoom-btn" onclick="zoomStep(0.25)" title="Приблизить">+</button>
|
||||||
|
<div class="zoom-level" id="zoom-level">100%</div>
|
||||||
|
<button class="zoom-btn" onclick="zoomStep(-0.25)" title="Отдалить">−</button>
|
||||||
|
<button class="zoom-btn" onclick="resetZoom()" title="Сбросить" style="font-size:10px">⟳</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="zoom-canvas">
|
||||||
|
<div class="img-frame" id="img-frame" style="display:none">
|
||||||
|
<img id="current-image" src="" alt="crop">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" id="empty-state">
|
||||||
|
<div class="icon">🗂</div>
|
||||||
|
<p>Нет изображений</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="input-row">
|
||||||
|
<span class="input-label">Текст →</span>
|
||||||
|
<input type="text" id="text-input" placeholder="Введите распознанный текст..." autocomplete="off" spellcheck="false">
|
||||||
|
<span class="auto-hint" id="auto-hint" style="display:none">🤖 авто — перепроверьте</span>
|
||||||
|
</div>
|
||||||
|
<div class="actions-row">
|
||||||
|
<div class="nav-btns">
|
||||||
|
<button class="btn" onclick="navigate(-1)">← Назад</button>
|
||||||
|
<button class="btn" onclick="navigate(1)">Пропустить →</button>
|
||||||
|
</div>
|
||||||
|
<div class="hotkeys">
|
||||||
|
<div class="hotkey"><span class="key">Enter</span> сохранить и далее</div>
|
||||||
|
<div class="hotkey"><span class="key">Tab</span> пропустить</div>
|
||||||
|
<div class="hotkey"><span class="key">Alt+←/→</span> навигация</div>
|
||||||
|
<div class="hotkey"><span class="key">колесо</span> зум</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div><span class="status-dot"></span><span id="status-msg">Готов</span></div>
|
||||||
|
<div id="current-filename">—</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Confirm delete dialog -->
|
||||||
|
<div class="confirm-overlay" id="confirm-overlay" style="display:none">
|
||||||
|
<div class="confirm-box">
|
||||||
|
<div class="confirm-icon">🗑</div>
|
||||||
|
<div class="confirm-title">Удалить изображение?</div>
|
||||||
|
<div class="confirm-sub" id="confirm-filename">—</div>
|
||||||
|
<div class="confirm-btns">
|
||||||
|
<button class="btn" id="btn-cancel-delete">Отмена</button>
|
||||||
|
<button class="btn btn-danger" id="btn-confirm-delete">Удалить навсегда</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
let images = [];
|
||||||
|
let currentIndex = 0;
|
||||||
|
let filter = 'all';
|
||||||
|
let currentSubfolder = null;
|
||||||
|
|
||||||
|
// ── Zoom state ─────────────────────────────────────────────────────────────
|
||||||
|
let scale = 1;
|
||||||
|
let translateX = 0;
|
||||||
|
let translateY = 0;
|
||||||
|
let isPanning = false;
|
||||||
|
let panStart = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
document.getElementById('zoom-canvas').style.transform =
|
||||||
|
`translate(${translateX}px, ${translateY}px) scale(${scale})`;
|
||||||
|
document.getElementById('zoom-level').textContent = Math.round(scale * 100) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomStep(delta) {
|
||||||
|
scale = Math.min(8, Math.max(0.25, scale + delta));
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom() {
|
||||||
|
scale = 1; translateX = 0; translateY = 0;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel zoom
|
||||||
|
document.addEventListener('wheel', e => {
|
||||||
|
const area = document.getElementById('image-area');
|
||||||
|
if (!area.contains(e.target)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY < 0 ? 0.15 : -0.15;
|
||||||
|
scale = Math.min(8, Math.max(0.25, scale + delta));
|
||||||
|
applyTransform();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Pan with mouse drag
|
||||||
|
const imageArea = document.getElementById('image-area');
|
||||||
|
imageArea.addEventListener('mousedown', e => {
|
||||||
|
if (e.target.closest('.zoom-controls')) return;
|
||||||
|
isPanning = true;
|
||||||
|
panStart = { x: e.clientX - translateX, y: e.clientY - translateY };
|
||||||
|
});
|
||||||
|
window.addEventListener('mousemove', e => {
|
||||||
|
if (!isPanning) return;
|
||||||
|
translateX = e.clientX - panStart.x;
|
||||||
|
translateY = e.clientY - panStart.y;
|
||||||
|
applyTransform();
|
||||||
|
});
|
||||||
|
window.addEventListener('mouseup', () => { isPanning = false; });
|
||||||
|
|
||||||
|
// ── Lobby ──────────────────────────────────────────────────────────────────
|
||||||
|
async function loadLobby() {
|
||||||
|
const res = await fetch('/api/subfolders');
|
||||||
|
const subs = await res.json();
|
||||||
|
const grid = document.getElementById('folder-grid');
|
||||||
|
|
||||||
|
if (subs.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="no-folders">Нет папок с изображениями.<br>Создайте подпапки внутри <code>data/images/</code></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
subs.forEach(s => {
|
||||||
|
const pct = s.total > 0 ? (s.labeled / s.total * 100) : 0;
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'folder-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="folder-name">📁 ${s.name}</div>
|
||||||
|
<div class="folder-bar"><div class="folder-bar-fill" style="width:${pct}%"></div></div>
|
||||||
|
<div class="folder-stats">
|
||||||
|
<span class="done">${s.labeled} готово</span>
|
||||||
|
<span>${s.total} файлов</span>
|
||||||
|
</div>`;
|
||||||
|
card.onclick = () => openSubfolder(s.name);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSubfolder(name) {
|
||||||
|
currentSubfolder = name;
|
||||||
|
document.getElementById('subfolder-tag').textContent = name;
|
||||||
|
document.getElementById('lobby').style.display = 'none';
|
||||||
|
document.getElementById('app').style.display = 'grid';
|
||||||
|
await loadImages();
|
||||||
|
startPolling();
|
||||||
|
resumeAutolabelIfRunning();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goLobby() {
|
||||||
|
stopPolling();
|
||||||
|
clearInterval(autolabelTimer);
|
||||||
|
currentSubfolder = null;
|
||||||
|
document.getElementById('app').style.display = 'none';
|
||||||
|
document.getElementById('lobby').style.display = 'flex';
|
||||||
|
loadLobby();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polling for multi-user sync ────────────────────────────────────────────
|
||||||
|
let pollTimer = null;
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (!currentSubfolder) return;
|
||||||
|
const res = await fetch(`/api/images/${encodeURIComponent(currentSubfolder)}`);
|
||||||
|
const fresh = await res.json();
|
||||||
|
// Update labels without disrupting current selection
|
||||||
|
fresh.forEach(f => {
|
||||||
|
const img = images.find(i => i.filename === f.filename);
|
||||||
|
if (img) { img.labeled = f.labeled; img.text = f.text; }
|
||||||
|
});
|
||||||
|
updateProgress();
|
||||||
|
renderList();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image list ─────────────────────────────────────────────────────────────
|
||||||
|
async function loadImages() {
|
||||||
|
const res = await fetch(`/api/images/${encodeURIComponent(currentSubfolder)}`);
|
||||||
|
images = await res.json();
|
||||||
|
updateProgress();
|
||||||
|
renderList();
|
||||||
|
const firstUnlabeled = images.findIndex(i => !i.labeled);
|
||||||
|
selectImage(firstUnlabeled >= 0 ? firstUnlabeled : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredImages() {
|
||||||
|
if (filter === 'labeled') return images.filter(i => i.labeled);
|
||||||
|
if (filter === 'unlabeled') return images.filter(i => !i.labeled);
|
||||||
|
if (filter === 'auto') return images.filter(i => i.labeled && i.auto);
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const list = document.getElementById('image-list');
|
||||||
|
const items = filteredImages();
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<div style="padding:14px;font-size:10px;color:var(--muted);text-align:center">Нет файлов</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach(img => {
|
||||||
|
const realIdx = images.indexOf(img);
|
||||||
|
const div = document.createElement('div');
|
||||||
|
const stateClass = img.labeled ? (img.auto ? ' auto' : ' labeled') : '';
|
||||||
|
div.className = 'image-item' + stateClass + (realIdx === currentIndex ? ' active' : '');
|
||||||
|
const badge = img.auto ? '<span class="item-badge">🤖</span>' : '';
|
||||||
|
div.innerHTML = `<div class="dot"></div><div class="item-text">${img.filename}</div>${badge}`;
|
||||||
|
div.onclick = () => selectImage(realIdx);
|
||||||
|
list.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectImage(index) {
|
||||||
|
if (!images.length) return;
|
||||||
|
index = Math.max(0, Math.min(index, images.length - 1));
|
||||||
|
currentIndex = index;
|
||||||
|
const img = images[index];
|
||||||
|
|
||||||
|
document.getElementById('img-frame').style.display = 'block';
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
document.getElementById('current-image').src =
|
||||||
|
`/images/${encodeURIComponent(currentSubfolder)}/${encodeURIComponent(img.filename)}`;
|
||||||
|
document.getElementById('current-filename').textContent = img.filename;
|
||||||
|
document.getElementById('text-input').value = img.text || '';
|
||||||
|
document.getElementById('auto-hint').style.display = img.auto ? 'inline-block' : 'none';
|
||||||
|
document.getElementById('text-input').focus();
|
||||||
|
resetZoom();
|
||||||
|
renderList();
|
||||||
|
scrollActiveIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollActiveIntoView() {
|
||||||
|
const active = document.querySelector('.image-item.active');
|
||||||
|
if (active) active.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save / nav ─────────────────────────────────────────────────────────────
|
||||||
|
async function saveLabel() {
|
||||||
|
if (!images.length) return;
|
||||||
|
const img = images[currentIndex];
|
||||||
|
const text = document.getElementById('text-input').value.trim();
|
||||||
|
const res = await fetch(`/api/label/${encodeURIComponent(currentSubfolder)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename: img.filename, text })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
img.text = text;
|
||||||
|
img.labeled = text.length > 0;
|
||||||
|
img.auto = false; // manual save = human-verified
|
||||||
|
document.getElementById('auto-hint').style.display = 'none';
|
||||||
|
updateProgress();
|
||||||
|
showToast(text ? `✓ ${text}` : 'Метка удалена');
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndNext() { await saveLabel(); navigate(1); }
|
||||||
|
|
||||||
|
function navigate(dir) {
|
||||||
|
const items = filteredImages();
|
||||||
|
const curFiltered = items.indexOf(images[currentIndex]);
|
||||||
|
const next = curFiltered + dir;
|
||||||
|
if (next >= 0 && next < items.length) {
|
||||||
|
selectImage(images.indexOf(items[next]));
|
||||||
|
} else if (dir > 0 && currentIndex < images.length - 1) {
|
||||||
|
selectImage(currentIndex + 1);
|
||||||
|
} else if (dir < 0 && currentIndex > 0) {
|
||||||
|
selectImage(currentIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
const total = images.length;
|
||||||
|
const labeled = images.filter(i => i.labeled).length;
|
||||||
|
const pct = total > 0 ? (labeled / total * 100) : 0;
|
||||||
|
const auto = images.filter(i => i.auto).length;
|
||||||
|
document.getElementById('progress-fill').style.width = pct + '%';
|
||||||
|
document.getElementById('labeled-count').textContent = labeled;
|
||||||
|
document.getElementById('total-count').textContent = total;
|
||||||
|
document.getElementById('recheck-text').textContent = auto ? ` · ${auto} 🤖 на проверку` : '';
|
||||||
|
document.getElementById('status-msg').textContent =
|
||||||
|
total > 0 ? `${total - labeled} осталось` : 'Нет файлов';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(f, btn) {
|
||||||
|
filter = f;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportLabels() {
|
||||||
|
window.location.href = '/api/export';
|
||||||
|
showToast('Экспорт dataset.zip...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-labeling (TrOCR) ────────────────────────────────────────────────────
|
||||||
|
let autolabelTimer = null;
|
||||||
|
|
||||||
|
async function startAutolabel() {
|
||||||
|
if (!currentSubfolder) return;
|
||||||
|
const remaining = images.filter(i => !i.labeled).length;
|
||||||
|
if (remaining === 0) { showToast('Нет неразмеченных изображений'); return; }
|
||||||
|
if (!confirm(
|
||||||
|
`Авто-разметить ${remaining} неразмеченных изображений моделью TrOCR?\n\n` +
|
||||||
|
`Метки сохранятся с пометкой «🤖 требует перепроверки» — их нужно вручную проверить.\n` +
|
||||||
|
`Первый запуск может занять время: модель скачивается из HuggingFace.`
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('autolabel-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
document.getElementById('status-msg').textContent = '🤖 Запуск авто-разметки...';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
showToast(d.error || 'Не удалось запустить', true);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Ошибка: ' + err.message, true);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollAutolabel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollAutolabel() {
|
||||||
|
clearInterval(autolabelTimer);
|
||||||
|
document.getElementById('autolabel-btn').disabled = true;
|
||||||
|
autolabelTimer = setInterval(async () => {
|
||||||
|
if (!currentSubfolder) { clearInterval(autolabelTimer); return; }
|
||||||
|
let s;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}/status`);
|
||||||
|
s = await res.json();
|
||||||
|
} catch (e) { return; }
|
||||||
|
|
||||||
|
if (s.state === 'running') {
|
||||||
|
const pct = s.total ? Math.round(s.done / s.total * 100) : 0;
|
||||||
|
document.getElementById('status-msg').textContent =
|
||||||
|
`🤖 Авто-разметка: ${s.done}/${s.total} (${pct}%)`;
|
||||||
|
} else if (s.state === 'done') {
|
||||||
|
clearInterval(autolabelTimer);
|
||||||
|
document.getElementById('autolabel-btn').disabled = false;
|
||||||
|
showToast('Авто-разметка завершена — проверьте метки 🤖');
|
||||||
|
await loadImages();
|
||||||
|
} else if (s.state === 'error') {
|
||||||
|
clearInterval(autolabelTimer);
|
||||||
|
document.getElementById('autolabel-btn').disabled = false;
|
||||||
|
showToast('Ошибка авто-разметки: ' + (s.error || ''), true);
|
||||||
|
updateProgress();
|
||||||
|
} else {
|
||||||
|
clearInterval(autolabelTimer);
|
||||||
|
document.getElementById('autolabel-btn').disabled = false;
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeAutolabelIfRunning() {
|
||||||
|
if (!currentSubfolder) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/autolabel/${encodeURIComponent(currentSubfolder)}/status`);
|
||||||
|
const s = await res.json();
|
||||||
|
if (s.state === 'running') pollAutolabel();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||||
|
function confirmDelete() {
|
||||||
|
if (!images.length) return;
|
||||||
|
const img = images[currentIndex];
|
||||||
|
document.getElementById('confirm-filename').textContent = img.filename;
|
||||||
|
document.getElementById('confirm-overlay').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirm() {
|
||||||
|
document.getElementById('confirm-overlay').style.display = 'none';
|
||||||
|
try { document.getElementById('text-input').focus(); } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!images.length) return;
|
||||||
|
// Snapshot before closing (focus() can trigger blur events)
|
||||||
|
const subfolder = currentSubfolder;
|
||||||
|
const img = images[currentIndex];
|
||||||
|
const idx = currentIndex;
|
||||||
|
|
||||||
|
document.getElementById('confirm-overlay').style.display = 'none';
|
||||||
|
|
||||||
|
let res, data;
|
||||||
|
try {
|
||||||
|
res = await fetch(`/api/delete/${encodeURIComponent(subfolder)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename: img.filename })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
data = await res.json();
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Delete error:', err);
|
||||||
|
showToast(`Ошибка: ${err.message}`, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data && data.ok) {
|
||||||
|
showToast(`Удалено: ${img.filename}`, true);
|
||||||
|
images.splice(idx, 1);
|
||||||
|
updateProgress();
|
||||||
|
if (images.length === 0) {
|
||||||
|
document.getElementById('img-frame').style.display = 'none';
|
||||||
|
document.getElementById('empty-state').style.display = 'flex';
|
||||||
|
document.getElementById('current-filename').textContent = '—';
|
||||||
|
document.getElementById('text-input').value = '';
|
||||||
|
renderList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectImage(Math.min(idx, images.length - 1));
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка удаления', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up dialog buttons
|
||||||
|
document.getElementById('btn-confirm-delete').addEventListener('click', doDelete);
|
||||||
|
document.getElementById('btn-cancel-delete').addEventListener('click', closeConfirm);
|
||||||
|
document.getElementById('confirm-overlay').addEventListener('click', e => {
|
||||||
|
if (e.target === document.getElementById('confirm-overlay')) closeConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showToast(msg, isError = false) {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = 'toast show' + (isError ? ' error' : '');
|
||||||
|
clearTimeout(t._timer);
|
||||||
|
t._timer = setTimeout(() => t.className = 'toast', 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hotkeys ────────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('text-input').addEventListener('keydown', async e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); await saveAndNext(); }
|
||||||
|
if (e.key === 'Tab') { e.preventDefault(); navigate(1); }
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
|
||||||
|
if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
|
||||||
|
if (e.key === 'Escape') closeConfirm();
|
||||||
|
if (e.key === 'Delete' && e.target.tagName !== 'INPUT') confirmDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Init ───────────────────────────────────────────────────────────────────
|
||||||
|
loadLobby();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue